From 1169330104f9ec43070302cf4c4c5df1ade1218c Mon Sep 17 00:00:00 2001 From: QUSETIONS <1418458861@qq.com> Date: Sat, 6 Jun 2026 13:15:35 +0800 Subject: [PATCH] Productize minicode-lite runtime and provider surfaces --- benchmarks/release_readiness.py | 300 +++ benchmarks/release_readiness_results.json | 101 + benchmarks/release_readiness_results.md | 44 + benchmarks/runtime_profile_eval.py | 187 ++ benchmarks/runtime_profile_eval_results.json | 161 ++ benchmarks/runtime_profile_eval_results.md | 30 + ...6-05-minicode-lite-productization-build.md | 72 + ...-05-minicode-lite-productization-verify.md | 106 + ...-05-minicode-lite-productization-design.md | 134 ++ minicode/agent_loop.py | 884 +++++-- minicode/anthropic_adapter.py | 23 +- minicode/cli_commands.py | 522 ++++- minicode/config.py | 306 +++ minicode/cybernetic_orchestrator.py | 5 +- minicode/file_review.py | 7 + minicode/headless.py | 1 + minicode/hooks.py | 30 + minicode/main.py | 231 +- minicode/model_registry.py | 3 +- minicode/model_switcher.py | 207 +- minicode/product_surfaces.py | 463 ++++ minicode/prompt.py | 100 +- minicode/release_readiness.py | 192 ++ minicode/runtime_profile_eval.py | 327 +++ minicode/runtime_profiles.py | 71 + minicode/session.py | 813 ++++++- minicode/timeline_memory.py | 2040 ++++++++++++++++- minicode/tooling.py | 1 + minicode/tty_app.py | 4 + minicode/tui/input_handler.py | 106 +- minicode/tui/renderer.py | 45 +- minicode/tui/session_flow.py | 40 +- minicode/tui/state.py | 2 + minicode/tui/transcript.py | 125 +- minicode/tui/types.py | 24 + minicode/turn_kernel.py | 890 +++++++ minicode/types.py | 23 + minicode/working_memory.py | 8 +- .../.comet.yaml | 16 + .../.comet/handoff/design-context.json | 18 + .../.comet/handoff/design-context.md | 457 ++++ .../.openspec.yaml | 2 + .../design.md | 161 ++ .../proposal.md | 60 + .../delegated-background-runtime/spec.md | 43 + .../specs/extension-packaging/spec.md | 29 + .../specs/first-class-hook-workflows/spec.md | 40 + .../specs/instruction-policy-layers/spec.md | 44 + .../product-readiness-evaluation/spec.md | 39 + .../tasks.md | 35 + .../delegated-background-runtime/spec.md | 43 + openspec/specs/extension-packaging/spec.md | 29 + .../specs/first-class-hook-workflows/spec.md | 40 + .../specs/instruction-policy-layers/spec.md | 44 + .../product-readiness-evaluation/spec.md | 39 + py-src/minicode/agent_loop.py | 513 +++-- py-src/minicode/tui/chrome.py | 92 +- py-src/minicode/tui/input_handler.py | 151 +- py-src/minicode/tui/tool_helpers.py | 41 +- py-src/minicode/tui/tool_lifecycle.py | 4 +- py-src/minicode/tui/transcript.py | 1408 +++++++++++- py-src/minicode/turn_kernel.py | 646 ++++++ py-src/minicode/working_memory.py | 5 +- py-src/tests/test_agent_loop.py | 340 ++- py-src/tests/test_experiments.py | 644 ++++++ py-src/tests/test_tty_app.py | 1581 ++++++++++++- py-src/tests/test_turn_kernel.py | 558 +++++ tests/test_agent_loop.py | 517 ++++- tests/test_anthropic_adapter.py | 66 +- tests/test_cli_commands.py | 477 ++++ tests/test_config.py | 141 +- tests/test_cybernetic_ablation.py | 165 ++ tests/test_cybernetic_supervisor.py | 96 + tests/test_headless.py | 123 + tests/test_main.py | 97 + tests/test_memory_curator.py | 132 ++ tests/test_model_selection_controller.py | 81 + tests/test_product_surfaces.py | 61 + tests/test_progress_controller.py | 69 + tests/test_release_readiness.py | 142 ++ tests/test_runtime_profile_eval.py | 247 ++ tests/test_runtime_profiles.py | 29 + tests/test_session.py | 678 ++++++ tests/test_timeline_memory.py | 967 ++++++++ tests/test_tools.py | 23 + tests/test_tty_app.py | 487 +++- tests/test_turn_kernel.py | 243 ++ tests/test_verification_controller.py | 115 + 88 files changed, 20106 insertions(+), 600 deletions(-) create mode 100644 benchmarks/release_readiness.py create mode 100644 benchmarks/release_readiness_results.json create mode 100644 benchmarks/release_readiness_results.md create mode 100644 benchmarks/runtime_profile_eval.py create mode 100644 benchmarks/runtime_profile_eval_results.json create mode 100644 benchmarks/runtime_profile_eval_results.md create mode 100644 docs/superpowers/plans/2026-06-05-minicode-lite-productization-build.md create mode 100644 docs/superpowers/reports/2026-06-05-minicode-lite-productization-verify.md create mode 100644 docs/superpowers/specs/2026-06-05-minicode-lite-productization-design.md create mode 100644 minicode/product_surfaces.py create mode 100644 minicode/release_readiness.py create mode 100644 minicode/runtime_profile_eval.py create mode 100644 minicode/runtime_profiles.py create mode 100644 minicode/turn_kernel.py create mode 100644 openspec/changes/archive/2026-06-05-minicode-lite-productization/.comet.yaml create mode 100644 openspec/changes/archive/2026-06-05-minicode-lite-productization/.comet/handoff/design-context.json create mode 100644 openspec/changes/archive/2026-06-05-minicode-lite-productization/.comet/handoff/design-context.md create mode 100644 openspec/changes/archive/2026-06-05-minicode-lite-productization/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-05-minicode-lite-productization/design.md create mode 100644 openspec/changes/archive/2026-06-05-minicode-lite-productization/proposal.md create mode 100644 openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/delegated-background-runtime/spec.md create mode 100644 openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/extension-packaging/spec.md create mode 100644 openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/first-class-hook-workflows/spec.md create mode 100644 openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/instruction-policy-layers/spec.md create mode 100644 openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/product-readiness-evaluation/spec.md create mode 100644 openspec/changes/archive/2026-06-05-minicode-lite-productization/tasks.md create mode 100644 openspec/specs/delegated-background-runtime/spec.md create mode 100644 openspec/specs/extension-packaging/spec.md create mode 100644 openspec/specs/first-class-hook-workflows/spec.md create mode 100644 openspec/specs/instruction-policy-layers/spec.md create mode 100644 openspec/specs/product-readiness-evaluation/spec.md create mode 100644 py-src/minicode/turn_kernel.py create mode 100644 py-src/tests/test_experiments.py create mode 100644 py-src/tests/test_turn_kernel.py create mode 100644 tests/test_cybernetic_ablation.py create mode 100644 tests/test_cybernetic_supervisor.py create mode 100644 tests/test_headless.py create mode 100644 tests/test_main.py create mode 100644 tests/test_memory_curator.py create mode 100644 tests/test_model_selection_controller.py create mode 100644 tests/test_product_surfaces.py create mode 100644 tests/test_progress_controller.py create mode 100644 tests/test_release_readiness.py create mode 100644 tests/test_runtime_profile_eval.py create mode 100644 tests/test_runtime_profiles.py create mode 100644 tests/test_turn_kernel.py create mode 100644 tests/test_verification_controller.py diff --git a/benchmarks/release_readiness.py b/benchmarks/release_readiness.py new file mode 100644 index 0000000..135a6e7 --- /dev/null +++ b/benchmarks/release_readiness.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import json +import subprocess +from datetime import datetime, timezone +from pathlib import Path +import sys + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from minicode.release_readiness import ( + ReleaseCheck, + classify_provider_outcome, + release_readiness_as_dict, + release_readiness_as_markdown, + summarize_release_status, +) +from minicode.product_surfaces import build_readiness_report +from minicode.session import create_file_checkpoint, create_new_session, save_session + + +REPO_ROOT = Path(__file__).resolve().parents[1] +BENCHMARKS_DIR = REPO_ROOT / "benchmarks" + + +def _run_command(label: str, command: list[str], *, cwd: Path, timeout: int = 1800) -> ReleaseCheck: + try: + completed = subprocess.run( + command, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + ) + stdout = completed.stdout.strip() + stderr = completed.stderr.strip() + summary_source = stdout or stderr + summary = summary_source.splitlines()[-1].strip() if summary_source else f"{label} completed." + status = "passed" if completed.returncode == 0 else "failed" + return ReleaseCheck( + label=label, + command=" ".join(command), + exit_code=completed.returncode, + status=status, + summary=summary, + stdout=stdout, + stderr=stderr, + ) + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout or "" + stderr = exc.stderr or "" + return ReleaseCheck( + label=label, + command=" ".join(command), + exit_code=124, + status="failed", + summary=f"{label} timed out.", + stdout=stdout if isinstance(stdout, str) else "", + stderr=stderr if isinstance(stderr, str) else "", + ) + + +def _prepare_saved_session(workspace: Path) -> None: + workspace.mkdir(parents=True, exist_ok=True) + target = workspace / "demo.txt" + target.write_text("after", encoding="utf-8") + extension_dir = workspace / ".mini-code" / "extensions" / "git-helpers" + extension_dir.mkdir(parents=True, exist_ok=True) + (extension_dir / "extension.json").write_text( + json.dumps( + { + "name": "git-helpers", + "version": "1.0.0", + "description": "Local helper bundle", + "enabled": True, + "entrypoint": "bundle.py", + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + (extension_dir / "bundle.py").write_text("print('ok')\n", encoding="utf-8") + + session = create_new_session(workspace=str(workspace)) + session.history = ["continue with runtime trace"] + session.transcript_entries = [ + { + "id": 1, + "kind": "progress", + "category": "runtime", + "runtimeKind": "phase", + "runtimeStep": 2, + "runtimePhase": "verify", + "body": "Runtime phase: verify.", + }, + { + "id": 2, + "kind": "tool", + "toolName": "edit_file", + "status": "success", + "body": "Patched demo.txt", + }, + ] + session.instruction_layers = [ + { + "name": "project-managed", + "scope": "project", + "kind": "managed", + "path": str(workspace / ".mini-code" / "MANAGED.md"), + "exists": True, + "preview": "Prefer verification-first delivery.", + } + ] + session.hook_status = { + "total_hooks": 1, + "enabled_hooks": 1, + "total_calls": 1, + "total_duration_ms": 8, + "failure_count": 0, + "last_status": "success", + } + session.delegated_tasks = [{"label": "lint-worker", "status": "running"}] + session.delegation_status = { + "running_tasks": 1, + "total_tracked": 1, + "max_slots": 4, + "available_slots": 3, + "active_labels": ["lint-worker"], + } + session.extension_manifests = [ + { + "name": "git-helpers", + "scope": "project", + "enabled": True, + "version": "1.0.0", + "description": "Local helper bundle", + "entrypoint": "bundle.py", + } + ] + session.readiness_report = { + "status": "ready", + "provider": "anthropic-compatible", + "provider_ready": True, + "issues": [], + } + create_file_checkpoint( + session, + file_path=str(target), + existed=True, + previous_content="before", + ) + save_session(session) + + +def main() -> None: + generated_at = datetime.now(timezone.utc).isoformat() + workspace = REPO_ROOT / "outputs" / "release_smoke_workspace" + _prepare_saved_session(workspace) + + compile_check = _run_command( + "compileall", + [sys.executable, "-m", "compileall", "-q", "minicode", "tests", "benchmarks"], + cwd=REPO_ROOT, + timeout=600, + ) + test_check = _run_command( + "pytest-q", + [sys.executable, "-m", "pytest", "-q"], + cwd=REPO_ROOT, + timeout=2400, + ) + runtime_eval_check = _run_command( + "runtime-profile-eval", + [sys.executable, "benchmarks/runtime_profile_eval.py"], + cwd=REPO_ROOT, + timeout=600, + ) + + smoke_checks = [ + _run_command( + "list-sessions", + [sys.executable, "-m", "minicode.main", "--list-sessions"], + cwd=workspace, + timeout=120, + ), + _run_command( + "inspect-session", + [sys.executable, "-m", "minicode.main", "--inspect-session", "latest"], + cwd=workspace, + timeout=120, + ), + _run_command( + "replay-session", + [sys.executable, "-m", "minicode.main", "--replay-session", "latest"], + cwd=workspace, + timeout=120, + ), + _run_command( + "preview-rewind", + [sys.executable, "-m", "minicode.main", "--preview-rewind", "latest"], + cwd=workspace, + timeout=120, + ), + ] + + runtime_profile_json = BENCHMARKS_DIR / "runtime_profile_eval_results.json" + provider_diagnostics: list[dict[str, object]] = [] + if runtime_profile_json.exists(): + payload = json.loads(runtime_profile_json.read_text(encoding="utf-8")) + provider_diagnostics = list(payload.get("provider_diagnostics", []) or []) + + if not provider_diagnostics: + fallback_check = _run_command( + "headless-provider-smoke", + [sys.executable, "-m", "minicode.headless", "Reply with exactly OK."], + cwd=REPO_ROOT, + timeout=180, + ) + outcome, summary = classify_provider_outcome( + exit_code=fallback_check.exit_code, + stdout=fallback_check.stdout, + stderr=fallback_check.stderr, + ) + provider_diagnostics = [ + { + "label": fallback_check.label, + "outcome": outcome, + "command": fallback_check.command, + "exit_code": fallback_check.exit_code, + "summary": summary, + "stdout": fallback_check.stdout, + "stderr": fallback_check.stderr, + } + ] + + readiness_report = build_readiness_report(REPO_ROOT) + + readiness_snapshot = { + "provider": readiness_report.provider, + "provider_ready": readiness_report.provider_ready, + "provider_channel": readiness_report.provider_channel, + "fallback_ready": readiness_report.fallback_ready, + "fallback_candidates": readiness_report.fallback_candidates, + "viable_fallbacks": readiness_report.viable_fallbacks, + "fallback_guidance": readiness_report.fallback_guidance, + "issues": readiness_report.issues, + "summary": readiness_report.summary, + } + + status = summarize_release_status( + compile_check=compile_check, + test_check=test_check, + runtime_eval_check=runtime_eval_check, + smoke_checks=smoke_checks, + provider_outcomes=[str(item.get("outcome", "error")) for item in provider_diagnostics], + readiness_report=readiness_snapshot, + ) + + runtime_profile_artifacts = { + "json": str(runtime_profile_json), + "markdown": str(BENCHMARKS_DIR / "runtime_profile_eval_results.md"), + } + payload = release_readiness_as_dict( + generated_at=generated_at, + status=status, + compile_check=compile_check, + test_check=test_check, + runtime_eval_check=runtime_eval_check, + smoke_checks=smoke_checks, + provider_diagnostics=provider_diagnostics, + runtime_profile_artifacts=runtime_profile_artifacts, + readiness_report=readiness_snapshot, + ) + markdown = release_readiness_as_markdown( + generated_at=generated_at, + status=status, + compile_check=compile_check, + test_check=test_check, + runtime_eval_check=runtime_eval_check, + smoke_checks=smoke_checks, + provider_diagnostics=provider_diagnostics, + runtime_profile_artifacts=runtime_profile_artifacts, + readiness_report=readiness_snapshot, + ) + + json_path = BENCHMARKS_DIR / "release_readiness_results.json" + markdown_path = BENCHMARKS_DIR / "release_readiness_results.md" + json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + markdown_path.write_text(markdown, encoding="utf-8") + print(json_path) + print(markdown_path) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/release_readiness_results.json b/benchmarks/release_readiness_results.json new file mode 100644 index 0000000..d24acfb --- /dev/null +++ b/benchmarks/release_readiness_results.json @@ -0,0 +1,101 @@ +{ + "generated_at": "2026-06-06T03:46:02.433983+00:00", + "status": "at-risk", + "compile_check": { + "label": "compileall", + "command": "C:\\Users\\question\\anaconda3\\python.exe -m compileall -q minicode tests benchmarks", + "exit_code": 0, + "status": "passed", + "summary": "compileall completed.", + "stdout": "", + "stderr": "" + }, + "test_check": { + "label": "pytest-q", + "command": "C:\\Users\\question\\anaconda3\\python.exe -m pytest -q", + "exit_code": 0, + "status": "passed", + "summary": "1027 passed, 2 skipped, 3 warnings in 18.88s", + "stdout": "........................................................................ [ 6%]\n........................................................................ [ 13%]\n........................................................................ [ 20%]\n........................................................................ [ 27%]\n........................................................................ [ 34%]\n........................................................................ [ 41%]\n...........................................................ss........... [ 48%]\n........................................................................ [ 55%]\n........................................................................ [ 62%]\n........................................................................ [ 69%]\n........................................................................ [ 76%]\n........................................................................ [ 83%]\n........................................................................ [ 90%]\n........................................................................ [ 97%]\n..................... [100%]\n============================== warnings summary ===============================\ntests\\test_memory_benchmark.py:7\n D:\\Desktop\\minicode\\tests\\test_memory_benchmark.py:7: PytestUnknownMarkWarning: Unknown pytest.mark.benchmark - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html\n @pytest.mark.benchmark\n\ntests\\test_memory_benchmark.py:22\n D:\\Desktop\\minicode\\tests\\test_memory_benchmark.py:22: PytestUnknownMarkWarning: Unknown pytest.mark.benchmark - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html\n @pytest.mark.benchmark\n\ntests\\test_memory_benchmark.py:37\n D:\\Desktop\\minicode\\tests\\test_memory_benchmark.py:37: PytestUnknownMarkWarning: Unknown pytest.mark.benchmark - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html\n @pytest.mark.benchmark\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n1027 passed, 2 skipped, 3 warnings in 18.88s", + "stderr": "" + }, + "runtime_eval_check": { + "label": "runtime-profile-eval", + "command": "C:\\Users\\question\\anaconda3\\python.exe benchmarks/runtime_profile_eval.py", + "exit_code": 0, + "status": "passed", + "summary": "benchmarks\\runtime_profile_eval_results.md", + "stdout": "benchmarks\\runtime_profile_eval_results.json\nbenchmarks\\runtime_profile_eval_results.md", + "stderr": "StateObserver: degradation=0.75 confidence=0.95" + }, + "smoke_checks": [ + { + "label": "list-sessions", + "command": "C:\\Users\\question\\anaconda3\\python.exe -m minicode.main --list-sessions", + "exit_code": 0, + "status": "passed", + "summary": "Total: 1231 session(s)", + "stdout": "\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\n\u2551 \ud83e\udd16 MiniCode Python - Your Terminal Coding Assistant \u2551\n\u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563\n\u2551 Model: deepseek-v4-pro[1m] \u2551\n\u2551 CWD: D:\\Desktop\\minicode\\outputs\\release_smoke_workspace \u2551\n\u2551 cwd: D:\\Desktop\\minicode\\outputs\\release_smoke_workspace \u2551\n\u2551 extra allowed dirs: none \u2551\n\u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563\n\u2551 \ud83d\udcca Skills: 64 | MCP Servers: 0 | Transcript: 0 \u2551\n\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\n\n\ud83d\udca1 Quick Start Guide:\n \ud83d\udcdd Edit files: edit_file.py or patch_file.py\n \ud83d\udd0d Search code: /grep or grep_files tool\n \ud83c\udfc3 Run commands: /cmd or run_command tool\n \ud83e\udde0 Think deeply: Use sequential_thinking MCP tool\n \ud83d\udcda View skills: /skills\n \u2753 Get help: /help\n\n\ud83d\ude80 Try saying:\n \"\u5e2e\u6211\u5206\u6790\u8fd9\u4e2a\u9879\u76ee\u7684\u7ed3\u6784\"\n \"\u7528 TDD \u65b9\u5f0f\u5b9e\u73b0 XX \u529f\u80fd\"\n \"\u7cfb\u7edf\u6027\u5730\u8c03\u8bd5\u8fd9\u4e2a bug\"\n \"\u5e2e\u6211\u5199\u4e2a\u6280\u672f\u65b9\u6848\"\n\nSaved sessions:\n\n 1. [66214f79] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-176\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 2. [2ee1ba71] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-177\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 3. [82365c10] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-176\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 4. [16a63b9e] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-177\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 5. [fe455b2b] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-176\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 6. [a31d63f7] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-176\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 7. [d3cf4a5b] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-176\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 8. [db03254d] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-176\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 9. [e92cf2cd] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-176\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 10. [c0ec59f3] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-176\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 11. [68e4e25e] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-177\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 12. [dee035ef] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-177\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 13. [186e2993] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-177\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 14. [c96404a4] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-177\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 15. [9a4f6440] 2026-06-06 03:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-177\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 16. [54c860ee] 2026-06-06 03:46 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 17. [0a4fbec9] 2026-06-06 03:46 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 18. [e7ce1a94] 2026-06-06 03:46 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 19. [test-lis] 1970-01-12 13:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-176\\test_session_listing0\n Messages: 1 | First: Message 2\n\n 20. [test-lis] 1970-01-12 13:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-176\\test_session_listing0\n Messages: 1 | First: Message 1\n\n 21. [test-lis] 1970-01-12 13:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-176\\test_session_listing0\n Messages: 1 | First: Message 0\n\n 22. [test-int] 1970-01-12 13:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-176\\test_session_save_load_roundtr0\n Messages: 3 | First: Hello!\n\n 23. [2e226a0a] 2026-06-06 03:46 - D:\\Desktop\\minicode\\outputs\\release_smoke_workspace\n Messages: 0 | First: (empty)\n Checkpoints: 1\n Runtime: phase:verify@2\n\n 24. [825e4212] 2026-06-05 17:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-167\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 25. [6f50172c] 2026-06-05 17:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-167\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 26. [1cb010e5] 2026-06-05 17:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-167\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 27. [2d70d920] 2026-06-05 17:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-167\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 28. [7bf5c984] 2026-06-05 17:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-167\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 29. [7d2836a5] 2026-06-05 17:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-167\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 30. [ab2097be] 2026-06-05 17:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-167\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 31. [a4feffc2] 2026-06-05 17:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-167\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 32. [8dc6015e] 2026-06-05 17:17 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 33. [3a0782b0] 2026-06-05 17:17 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 34. [5b8b3479] 2026-06-05 17:17 - D:\\Desktop\\minicode\\outputs\\release_smoke_workspace\n Messages: 0 | First: (empty)\n Checkpoints: 1\n Runtime: phase:verify@2\n\n 35. [93529732] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-166\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 36. [6ec79276] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-165\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 37. [444623b6] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-166\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 38. [ddc8e1be] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-165\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 39. [6ca4ae98] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-166\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 40. [adfbe090] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-166\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 41. [2b667a16] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-166\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 42. [d2fa4809] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-166\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 43. [466b3082] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-166\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 44. [adaafa9c] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-166\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 45. [ed7f29a5] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-165\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 46. [ab6e98f4] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-165\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 47. [9a1d083d] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-165\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 48. [3dc90a19] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-165\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 49. [dd547b93] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-165\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 50. [984553c7] 2026-06-05 17:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-165\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 51. [b45832d6] 2026-06-05 17:14 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 52. [45a03e2f] 2026-06-05 17:14 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 53. [df7c365a] 2026-06-05 17:14 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 54. [5f5e1134] 2026-06-05 17:14 - D:\\Desktop\\minicode\\outputs\\release_smoke_workspace\n Messages: 0 | First: (empty)\n Checkpoints: 1\n Runtime: phase:verify@2\n\n 55. [c681b85e] 2026-06-05 13:52 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-160\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 56. [6c90734c] 2026-06-05 13:51 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-160\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 57. [25e951bd] 2026-06-05 13:51 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-160\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 58. [9a3f980a] 2026-06-05 13:51 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-160\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 59. [de02011a] 2026-06-05 13:51 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-160\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 60. [8fe38925] 2026-06-05 13:51 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-160\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 61. [2f950249] 2026-06-05 13:51 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-160\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 62. [2b9ea6ff] 2026-06-05 13:51 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-160\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 63. [bfae0e97] 2026-06-05 13:51 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 64. [04f74082] 2026-06-05 13:51 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 65. [4a6b4860] 2026-06-05 13:51 - D:\\Desktop\\minicode\\outputs\\release_smoke_workspace\n Messages: 0 | First: (empty)\n Checkpoints: 1\n Runtime: phase:verify@2\n\n 66. [f97c513e] 2026-06-05 13:50 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-159\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 67. [57190451] 2026-06-05 13:50 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-159\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 68. [b31bd9a5] 2026-06-05 13:50 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-159\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 69. [fa23d697] 2026-06-05 13:50 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-159\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 70. [9195770d] 2026-06-05 13:50 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-159\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 71. [3bc00911] 2026-06-05 13:50 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-159\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 72. [ac008c20] 2026-06-05 13:50 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-159\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 73. [9cfdc845] 2026-06-05 13:50 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-159\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 74. [58e18cf5] 2026-06-05 13:50 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 75. [69c8fe08] 2026-06-05 13:50 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 76. [3cacb2c1] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-158\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 77. [daeca245] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-157\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 78. [c43d787c] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-158\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 79. [8ff0d35d] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-158\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 80. [fe25cfe6] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-158\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 81. [5b140429] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-158\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 82. [dc4bc0bf] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-158\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 83. [1c22c754] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-158\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 84. [0dfd6130] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-158\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 85. [11643ebb] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-157\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 86. [9fceb909] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-157\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 87. [0ce57def] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-157\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 88. [d4db4dee] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-157\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 89. [6906eb63] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-157\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 90. [36a2fc6d] 2026-06-05 13:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-157\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 91. [7f1f8422] 2026-06-05 13:32 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 92. [d865fa03] 2026-06-05 13:32 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 93. [f3515ccf] 2026-06-05 13:32 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 94. [0184c74b] 2026-06-05 13:32 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 95. [72ff661f] 2026-06-05 13:32 - D:\\Desktop\\minicode\\outputs\\release_smoke_workspace\n Messages: 0 | First: (empty)\n Checkpoints: 1\n Runtime: phase:verify@2\n\n 96. [70e1fb01] 2026-06-05 13:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-155\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 97. [57c6cdd8] 2026-06-05 13:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-155\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 98. [bbca12db] 2026-06-05 13:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-155\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 99. [7b54d731] 2026-06-05 13:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-155\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 100. [ae67e06c] 2026-06-05 13:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-155\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 101. [313cc8d6] 2026-06-05 13:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-155\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 102. [574fc0df] 2026-06-05 13:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-155\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 103. [f555d8e5] 2026-06-05 13:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-155\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 104. [670b0b46] 2026-06-05 13:24 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 105. [0bd71f9b] 2026-06-05 13:24 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 106. [119d19e9] 2026-06-05 13:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-148\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 107. [da2db3c0] 2026-06-05 13:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-148\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 108. [23ab952e] 2026-06-05 13:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-148\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 109. [979b6cb4] 2026-06-05 13:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-148\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 110. [d85a3ef2] 2026-06-05 13:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-148\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 111. [2e3d9ede] 2026-06-05 13:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-148\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 112. [2e75f489] 2026-06-05 13:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-148\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 113. [ce20115a] 2026-06-05 13:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-148\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 114. [dcc857f1] 2026-06-05 13:02 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 115. [50d97853] 2026-06-05 13:02 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 116. [01975e43] 2026-06-05 13:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-147\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 117. [bb547a7b] 2026-06-05 13:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-147\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 118. [46ba4a86] 2026-06-05 13:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-147\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 119. [ccf65639] 2026-06-05 13:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-147\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 120. [ef0e847c] 2026-06-05 13:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-147\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 121. [fbaaf4d0] 2026-06-05 13:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-147\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 122. [a482947e] 2026-06-05 13:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-147\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 123. [db74da57] 2026-06-05 13:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-147\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 124. [bee73e00] 2026-06-05 13:01 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 125. [b419755f] 2026-06-05 13:01 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 126. [2afd9c9f] 2026-06-05 13:01 - D:\\Desktop\\minicode\\outputs\\release_smoke_workspace\n Messages: 0 | First: (empty)\n Checkpoints: 1\n Runtime: phase:verify@2\n\n 127. [2d09b968] 2026-06-05 13:01 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 128. [4e136969] 2026-06-05 13:01 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 129. [b27dac64] 2026-06-05 13:00 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 130. [78c2d120] 2026-06-05 13:00 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 131. [588cc72d] 2026-06-05 02:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-127\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 132. [9d6c2693] 2026-06-05 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-127\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 133. [76ce375f] 2026-06-05 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-127\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 134. [17d02518] 2026-06-05 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-127\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 135. [4cc1b68c] 2026-06-05 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-127\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 136. [8550a6c9] 2026-06-05 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-127\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 137. [bec6dfb8] 2026-06-05 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-127\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 138. [bcb37a2d] 2026-06-05 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-127\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 139. [9f0821a2] 2026-06-05 02:08 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 140. [0d1d0ddf] 2026-06-05 02:08 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 141. [4455264b] 2026-06-04 13:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-116\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 142. [e3c43ad1] 2026-06-04 13:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-116\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 143. [e5dd3310] 2026-06-04 13:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-116\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 144. [43627402] 2026-06-04 13:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-116\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 145. [380b94d2] 2026-06-04 13:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-116\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 146. [ad483f81] 2026-06-04 13:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-116\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 147. [45a130e0] 2026-06-04 13:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-116\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 148. [a2345341] 2026-06-04 13:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-116\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 149. [c5aa9b21] 2026-06-04 13:45 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 150. [341fc485] 2026-06-04 13:45 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 151. [790ddaaf] 2026-06-04 10:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-112\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 152. [ed9e9862] 2026-06-04 10:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-112\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 153. [9339ae03] 2026-06-04 10:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-112\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 154. [8476e638] 2026-06-04 10:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-112\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 155. [eaaf6bc4] 2026-06-04 10:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-112\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 156. [5462c7b5] 2026-06-04 10:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-112\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 157. [0a241fc6] 2026-06-04 10:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-112\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 158. [2518cdd5] 2026-06-04 10:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-112\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 159. [c832b166] 2026-06-04 10:36 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 160. [a5f9e8c6] 2026-06-04 10:36 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 161. [07f040f0] 2026-06-04 10:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-111\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 162. [0aedfd93] 2026-06-04 10:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-111\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 163. [5570e86f] 2026-06-04 10:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-111\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 164. [66f04696] 2026-06-04 10:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-111\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 165. [5a436a85] 2026-06-04 10:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-111\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 166. [88549855] 2026-06-04 10:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-111\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 167. [01982c73] 2026-06-04 10:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-111\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 168. [ab02b20c] 2026-06-04 10:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-111\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 169. [255d3548] 2026-06-04 10:31 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 170. [f1bed1e9] 2026-06-04 10:31 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 171. [99ca753b] 2026-06-04 10:00 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-106\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 172. [cccaea03] 2026-06-04 10:00 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-106\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 173. [18a83081] 2026-06-04 10:00 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-106\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 174. [8f5fef75] 2026-06-04 10:00 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-106\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 175. [23d50299] 2026-06-04 10:00 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-106\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 176. [e1591786] 2026-06-04 10:00 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-106\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 177. [e9cc01bb] 2026-06-04 10:00 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-106\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 178. [db3a039e] 2026-06-04 10:00 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-106\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 179. [74f94d70] 2026-06-04 10:00 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 180. [7e7d4855] 2026-06-04 10:00 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 181. [1e5eba43] 2026-06-04 10:00 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-105\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 182. [4dd532ed] 2026-06-04 10:00 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 183. [ae2bfdeb] 2026-06-04 10:00 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 184. [8fe0748f] 2026-06-04 09:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-100\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 185. [63aa50f6] 2026-06-04 09:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-100\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 186. [4ab71634] 2026-06-04 09:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-100\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 187. [64066b29] 2026-06-04 09:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-100\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 188. [5179ca75] 2026-06-04 09:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-100\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 189. [dd79cb5d] 2026-06-04 09:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-100\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 190. [702d5514] 2026-06-04 09:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-100\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 191. [1790da39] 2026-06-04 09:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-100\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 192. [ccb0c820] 2026-06-04 09:44 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 193. [f662b0e8] 2026-06-04 09:44 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 194. [78f9c386] 2026-06-04 09:44 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 195. [3057022b] 2026-06-04 09:44 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 196. [9f0fb210] 2026-06-04 09:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-99\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 197. [65b82263] 2026-06-04 09:44 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 198. [d04a45f6] 2026-06-04 09:44 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 199. [be181e00] 2026-06-04 09:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-96\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 200. [d746432c] 2026-06-04 09:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-96\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 201. [409b5190] 2026-06-04 09:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-96\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 202. [2e43046e] 2026-06-04 09:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-96\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 203. [32b625ca] 2026-06-04 09:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-96\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 204. [bd7cf613] 2026-06-04 09:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-96\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 205. [f71d54ca] 2026-06-04 09:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-96\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 206. [65bf2274] 2026-06-04 09:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-96\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 207. [73c42579] 2026-06-04 09:36 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 208. [7fda8c78] 2026-06-04 09:35 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 209. [725b7500] 2026-06-04 09:35 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 210. [983f3301] 2026-06-04 09:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-95\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 211. [fba3176e] 2026-06-04 09:35 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 212. [db1663b1] 2026-06-04 09:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-88\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 213. [6763f7c9] 2026-06-04 09:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-88\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 214. [9d51e2ae] 2026-06-04 09:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-88\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 215. [6b5c22b3] 2026-06-04 09:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-88\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 216. [75988c45] 2026-06-04 09:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-88\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 217. [4c6943fa] 2026-06-04 09:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-88\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 218. [8046a5fc] 2026-06-04 09:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-88\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 219. [bac63c16] 2026-06-04 09:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-88\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 220. [f787385d] 2026-06-04 09:12 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 221. [c85e224e] 2026-06-04 09:12 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 222. [13030f4d] 2026-06-04 09:12 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 223. [6c259906] 2026-06-04 09:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-87\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 224. [640ba787] 2026-06-04 09:11 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 225. [0a617369] 2026-06-04 09:11 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 226. [92b89396] 2026-06-04 09:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-85\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 227. [913f4c59] 2026-06-04 09:11 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 228. [bb688e7d] 2026-06-04 08:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 229. [b171eca8] 2026-06-04 08:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 230. [176d6414] 2026-06-04 08:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 231. [9805e2c6] 2026-06-04 08:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 232. [8be10af5] 2026-06-04 08:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 233. [2cd37e08] 2026-06-04 08:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 234. [ce795a58] 2026-06-04 08:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 235. [f428fb92] 2026-06-04 08:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 236. [0578a798] 2026-06-04 08:53 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 237. [66c97920] 2026-06-04 08:53 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 238. [42dcf684] 2026-06-04 08:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-79\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 239. [84048bcb] 2026-06-04 08:52 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 240. [0eff673b] 2026-06-04 08:41 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-76\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 241. [d86ef4d6] 2026-06-04 08:41 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-76\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 242. [e38aa189] 2026-06-04 08:41 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-76\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 243. [b6f52530] 2026-06-04 08:41 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-76\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 244. [4d07b897] 2026-06-04 08:41 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-76\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 245. [ad24d7db] 2026-06-04 08:41 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-76\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 246. [09dc5fce] 2026-06-04 08:41 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-76\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 247. [6c775315] 2026-06-04 08:41 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-76\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 248. [6ee4c57b] 2026-06-04 08:40 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 249. [2fa3963c] 2026-06-04 08:40 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 250. [3d295c50] 2026-06-04 08:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-75\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 251. [d29c2785] 2026-06-04 08:40 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 252. [61756bfd] 2026-06-03 13:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 253. [3687d317] 2026-06-03 13:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 254. [0570b902] 2026-06-03 13:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 255. [c6dd8cd9] 2026-06-03 13:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 256. [6aa1b50b] 2026-06-03 13:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 257. [dacadc7d] 2026-06-03 13:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 258. [8e46912a] 2026-06-03 13:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 259. [558db288] 2026-06-03 13:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 260. [3a6309b3] 2026-06-03 13:04 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 261. [44a58210] 2026-06-03 13:04 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 262. [584643de] 2026-06-03 13:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-67\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 263. [6c8efc13] 2026-06-03 13:03 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 264. [b260a245] 2026-06-03 12:59 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 265. [87f2405e] 2026-06-03 12:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-63\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 266. [33833d82] 2026-06-03 12:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-63\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 267. [6917a533] 2026-06-03 12:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-63\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 268. [510c6c57] 2026-06-03 12:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-63\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 269. [35b3ac2a] 2026-06-03 12:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-63\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 270. [e3171a18] 2026-06-03 12:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-63\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 271. [d65b32a6] 2026-06-03 12:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-63\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 272. [3eb2f46a] 2026-06-03 12:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-63\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 273. [9f279872] 2026-06-03 12:53 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 274. [12228f31] 2026-06-03 12:52 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 275. [b1f4c4bb] 2026-06-03 12:52 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-62\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 276. [029525f0] 2026-06-03 12:52 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 277. [4f9a7512] 2026-06-03 12:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 278. [6bed5dcf] 2026-06-03 12:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 279. [3e8ecfe8] 2026-06-03 12:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 280. [7888391f] 2026-06-03 12:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 281. [42fb25ac] 2026-06-03 12:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 282. [313aa119] 2026-06-03 12:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 283. [411ef98d] 2026-06-03 12:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 284. [271b69a3] 2026-06-03 12:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 285. [1244b31c] 2026-06-03 12:42 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 286. [09870b33] 2026-06-03 12:41 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-57\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 287. [8649701b] 2026-06-03 12:41 - /tmp/test\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 288. [32798a97] 2026-06-03 12:38 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 289. [aa70c845] 2026-06-03 12:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 290. [14aafc32] 2026-06-03 12:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 291. [0b1f0552] 2026-06-03 12:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 292. [6aa6f19b] 2026-06-03 12:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 293. [3b9a3537] 2026-06-03 12:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 294. [da073433] 2026-06-03 12:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 295. [b2b7ce66] 2026-06-03 12:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 296. [792a6a1a] 2026-06-03 12:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 297. [169eea14] 2026-06-03 12:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 298. [25734169] 2026-06-03 12:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 299. [246e1019] 2026-06-03 12:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 300. [3dad21a1] 2026-06-03 12:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 301. [caf71c70] 2026-06-03 12:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 302. [b7f88ad6] 2026-06-03 12:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 303. [5c758e70] 2026-06-03 12:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 304. [9d941a2c] 2026-06-03 12:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-54\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 305. [632c8db2] 2026-06-03 12:27 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-52\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 306. [f6ec5e90] 2026-06-03 12:21 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 307. [e70992b9] 2026-06-03 12:21 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 308. [a12f0c46] 2026-06-03 12:21 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 309. [af3843f0] 2026-06-03 12:21 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 310. [1876d02e] 2026-06-03 12:21 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 311. [da96d0a6] 2026-06-03 12:21 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 312. [00cd7714] 2026-06-03 12:21 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 313. [85ea0228] 2026-06-03 12:21 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 314. [4469be3c] 2026-06-03 12:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-49\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 315. [5c686ab0] 2026-06-03 12:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-49\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 316. [fb0ecfb6] 2026-06-03 12:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-49\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 317. [4d500819] 2026-06-03 12:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-49\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 318. [7119146a] 2026-06-03 12:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-49\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 319. [6cdc4434] 2026-06-03 12:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-49\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 320. [15173e18] 2026-06-03 12:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-49\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 321. [c706a016] 2026-06-03 12:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-49\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 322. [2c10a6a5] 2026-06-03 12:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-48\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 323. [5b856ea9] 2026-06-03 12:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 324. [e90124c4] 2026-06-03 12:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 325. [d8bd3249] 2026-06-03 12:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 326. [0c911648] 2026-06-03 12:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 327. [0f849661] 2026-06-03 12:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 328. [66c5b49c] 2026-06-03 12:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 329. [23114eb3] 2026-06-03 12:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 330. [9abf26d3] 2026-06-03 12:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 331. [c84b1b5e] 2026-06-03 12:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-46\\test_write_file_tool_records_c0\n Messages: 0 | First: (empty)\n Checkpoints: 1\n\n 332. [9a91084b] 2026-06-03 11:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 333. [8eddec6b] 2026-06-03 11:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 334. [01e6fcc8] 2026-06-03 11:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 335. [4cc32d5a] 2026-06-03 11:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 336. [61d39140] 2026-06-03 11:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 337. [6159da22] 2026-06-03 11:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 338. [c7c7baa9] 2026-06-03 11:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 339. [c53d2c52] 2026-06-03 10:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 340. [dedbb6ac] 2026-06-03 10:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 341. [e63ed100] 2026-06-03 10:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 342. [7f837b5f] 2026-06-03 10:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 343. [aefb83c2] 2026-06-03 10:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 344. [c5ac76b1] 2026-06-03 10:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 345. [8dfb5258] 2026-06-03 10:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 346. [56862d3f] 2026-06-03 10:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 347. [26d5f7d5] 2026-06-03 10:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 348. [1145f0c4] 2026-06-03 10:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 349. [381d2883] 2026-06-03 10:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 350. [bfa93951] 2026-06-03 10:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 351. [26fcffa5] 2026-06-03 10:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 352. [4ed06136] 2026-06-03 10:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 353. [4970dabc] 2026-06-03 10:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 354. [256596fc] 2026-06-03 10:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 355. [a4250e5f] 2026-06-03 10:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 356. [237da598] 2026-06-03 10:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 357. [6006a75f] 2026-06-03 10:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 358. [5eebed58] 2026-06-03 10:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 359. [1b40c7b6] 2026-06-03 10:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 360. [5696f261] 2026-06-03 09:32 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 361. [50e2bfb6] 2026-06-03 09:32 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 362. [6cb2fdb7] 2026-06-03 09:32 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 363. [ea87eb50] 2026-06-03 09:32 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 364. [b3b1a432] 2026-06-03 09:32 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 365. [35197627] 2026-06-03 09:32 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 366. [298d6103] 2026-06-03 09:32 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 367. [e760c83c] 2026-06-03 09:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 368. [63c1761f] 2026-06-03 09:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 369. [59acb20e] 2026-06-03 09:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 370. [3dbe4118] 2026-06-03 09:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 371. [c3114fae] 2026-06-03 09:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 372. [de2f5753] 2026-06-03 09:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 373. [2acee990] 2026-06-03 09:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 374. [ee8a89a7] 2026-06-03 09:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 375. [f584b9ce] 2026-06-03 09:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 376. [68b47a22] 2026-06-03 09:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 377. [017fabd8] 2026-06-03 09:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 378. [d2d700c2] 2026-06-03 09:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 379. [4a87e5db] 2026-06-03 09:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 380. [0db4bca5] 2026-06-03 09:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 381. [b10ce84f] 2026-06-03 08:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 382. [d718c0ab] 2026-06-03 08:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 383. [40b3635c] 2026-06-03 08:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 384. [232d524c] 2026-06-03 08:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 385. [efe5185b] 2026-06-03 08:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 386. [248029fb] 2026-06-03 08:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 387. [acc5be8c] 2026-06-03 08:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 388. [4db8d013] 2026-06-03 08:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 389. [05eb1a50] 2026-06-03 08:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 390. [c6392a8f] 2026-06-03 08:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 391. [98bb80a4] 2026-06-03 08:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 392. [7bc05802] 2026-06-03 08:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 393. [9f25d5f1] 2026-06-03 08:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 394. [bc14d01f] 2026-06-03 08:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 395. [4897ca2a] 2026-06-03 08:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 396. [2f22e29d] 2026-06-03 08:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 397. [4d168f98] 2026-06-03 08:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 398. [2cd9faf4] 2026-06-03 08:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 399. [73f4a415] 2026-06-03 08:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 400. [cc878fe0] 2026-06-03 08:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 401. [299dbbce] 2026-06-03 08:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 402. [914728ee] 2026-05-27 06:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-457\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 403. [1ec9893f] 2026-05-27 06:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-457\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 404. [bd11fa40] 2026-05-27 06:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-457\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 405. [ffca1362] 2026-05-27 06:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-457\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 406. [0da61312] 2026-05-27 06:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-457\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 407. [9a29d03b] 2026-05-27 06:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-457\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 408. [cdeddc03] 2026-05-27 06:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-457\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 409. [d082f8fd] 2026-05-27 04:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-450\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 410. [17254754] 2026-05-27 04:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-450\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 411. [2ed47869] 2026-05-27 04:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-450\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 412. [84c1a36b] 2026-05-27 04:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-450\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 413. [aa72d7fa] 2026-05-27 04:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-450\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 414. [04c50789] 2026-05-27 04:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-450\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 415. [fac33055] 2026-05-27 04:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-450\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 416. [814d3d5d] 2026-05-27 03:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-430\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 417. [cbb15bd3] 2026-05-27 03:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-430\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 418. [5e376a3e] 2026-05-27 03:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-430\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 419. [6a91e48b] 2026-05-27 03:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-430\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 420. [ed32b9f7] 2026-05-27 03:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-430\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 421. [f17ab01f] 2026-05-27 03:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-430\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 422. [35ab47a8] 2026-05-27 03:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-430\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 423. [ffd93388] 2026-05-27 02:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-427\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 424. [5f986ce7] 2026-05-27 02:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-427\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 425. [1b902c0f] 2026-05-27 02:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-427\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 426. [5f8be719] 2026-05-27 02:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-427\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 427. [eafb26c6] 2026-05-27 02:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-427\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 428. [4ce41322] 2026-05-27 02:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-427\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 429. [ebf31255] 2026-05-27 02:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-427\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 430. [e40659c7] 2026-05-26 13:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-426\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 431. [9793ec58] 2026-05-26 13:52 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-426\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 432. [e9c55d29] 2026-05-26 13:52 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-426\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 433. [d27ed0c8] 2026-05-26 13:52 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-426\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 434. [e35c0db5] 2026-05-26 13:52 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-426\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 435. [7f865cb4] 2026-05-26 13:52 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-426\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 436. [ce219aec] 2026-05-26 13:52 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-426\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 437. [20b7f153] 2026-05-26 13:39 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-425\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 438. [c2590c91] 2026-05-26 13:39 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-425\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 439. [b759790a] 2026-05-26 13:39 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-425\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 440. [0ba00883] 2026-05-26 13:39 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-425\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 441. [c9318c25] 2026-05-26 13:39 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-425\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 442. [8f570824] 2026-05-26 13:39 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-425\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 443. [4888f464] 2026-05-26 13:39 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-425\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 444. [e894d565] 2026-05-26 04:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-404\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 445. [d7045624] 2026-05-26 04:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-404\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 446. [4ac1d4e3] 2026-05-26 04:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-404\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 447. [f36cea19] 2026-05-26 04:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-404\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 448. [a42818ff] 2026-05-26 04:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-404\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 449. [58178847] 2026-05-26 04:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-404\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 450. [cf2c4224] 2026-05-26 04:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-404\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 451. [7ce5c8ce] 2026-05-26 04:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-400\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 452. [1a0cbc8e] 2026-05-26 04:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-400\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 453. [9b26d93e] 2026-05-26 04:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-400\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 454. [03494bcb] 2026-05-26 04:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-400\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 455. [01a0a69e] 2026-05-26 04:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-400\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 456. [0803f8ad] 2026-05-26 04:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-400\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 457. [a0eb4275] 2026-05-26 04:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-400\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 458. [a21084e8] 2026-05-21 13:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 459. [b32d616a] 2026-05-21 13:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 460. [10c8e3ae] 2026-05-21 13:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 461. [ef3f0995] 2026-05-21 13:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 462. [3d849a0a] 2026-05-21 13:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 463. [7a4ef6ef] 2026-05-21 13:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 464. [9a5ddb41] 2026-05-21 13:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-80\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 465. [86b9fa6a] 2026-05-21 09:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-79\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 466. [ef52251a] 2026-05-21 09:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-79\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 467. [48c98d70] 2026-05-21 09:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-79\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 468. [d6733e69] 2026-05-21 09:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-79\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 469. [426249fc] 2026-05-21 09:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-79\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 470. [59234015] 2026-05-21 09:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-79\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 471. [4cc2af43] 2026-05-21 09:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-79\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 472. [466a8293] 2026-05-21 08:54 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-75\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 473. [c9280956] 2026-05-21 08:54 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-75\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 474. [003e980f] 2026-05-21 08:54 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-75\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 475. [9794c598] 2026-05-21 08:54 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-75\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 476. [887f4244] 2026-05-21 08:54 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-75\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 477. [986a3bc6] 2026-05-21 08:54 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-75\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 478. [284a45f7] 2026-05-21 08:54 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-75\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 479. [a3ab0254] 2026-05-21 05:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-73\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 480. [71bff929] 2026-05-21 05:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-73\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 481. [4a443952] 2026-05-21 05:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-73\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 482. [51e32920] 2026-05-21 05:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-73\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 483. [b7b5ee54] 2026-05-21 05:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-73\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 484. [bcc53f3b] 2026-05-21 05:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-73\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 485. [5e2141f1] 2026-05-21 05:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-73\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 486. [98914728] 2026-05-21 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-72\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 487. [3f63544a] 2026-05-21 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-72\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 488. [88f9f0f6] 2026-05-21 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-72\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 489. [06b0a2e5] 2026-05-21 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-72\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 490. [a2ef3fc2] 2026-05-21 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-72\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 491. [d75c4b94] 2026-05-21 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-72\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 492. [72cf8c7f] 2026-05-21 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-72\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 493. [a474fbe9] 2026-05-21 05:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-71\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 494. [cc561f70] 2026-05-21 05:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-71\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 495. [ca0eebaf] 2026-05-21 05:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-71\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 496. [0044b9cf] 2026-05-21 05:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-71\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 497. [23627b58] 2026-05-21 05:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-71\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 498. [90fb6137] 2026-05-21 05:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-71\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 499. [988d6ca5] 2026-05-21 05:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-71\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 500. [176829a6] 2026-05-21 04:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-70\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 501. [67a4351c] 2026-05-21 04:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-70\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 502. [08e115b6] 2026-05-21 04:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-70\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 503. [563bf013] 2026-05-21 04:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-70\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 504. [50ec90b3] 2026-05-21 04:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-70\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 505. [f6d93d53] 2026-05-21 04:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-70\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 506. [f606b516] 2026-05-21 04:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-70\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 507. [7d9634d5] 2026-05-21 04:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-69\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 508. [728df6d8] 2026-05-21 04:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-69\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 509. [d0206537] 2026-05-21 04:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-69\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 510. [fffe4627] 2026-05-21 04:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-69\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 511. [cf121af4] 2026-05-21 04:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-69\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 512. [059ebbbf] 2026-05-21 04:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-69\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 513. [313b4d57] 2026-05-21 04:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-69\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 514. [d8c0287d] 2026-05-21 04:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 515. [d072dfd3] 2026-05-21 04:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 516. [ecb3461e] 2026-05-21 04:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 517. [6348d290] 2026-05-21 04:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 518. [f70fc2a4] 2026-05-21 04:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 519. [7008a24f] 2026-05-21 04:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 520. [c2f0a097] 2026-05-21 04:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-68\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 521. [20e2d6b6] 2026-05-21 04:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-67\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 522. [cb384eb2] 2026-05-21 04:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-67\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 523. [ca52afdb] 2026-05-21 04:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-67\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 524. [b9ccb76c] 2026-05-21 04:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-67\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 525. [b112543a] 2026-05-21 04:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-67\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 526. [786468a1] 2026-05-21 04:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-67\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 527. [0ad492c5] 2026-05-21 04:03 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-67\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 528. [463341b3] 2026-05-21 04:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-65\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 529. [e64712c4] 2026-05-21 04:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-65\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 530. [fe65f210] 2026-05-21 04:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-65\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 531. [31a1a184] 2026-05-21 04:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-65\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 532. [183fdc25] 2026-05-21 04:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-65\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 533. [982e1618] 2026-05-21 04:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-65\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 534. [810c3544] 2026-05-21 04:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-65\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 535. [8202ceff] 2026-05-21 03:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-64\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 536. [d498cc61] 2026-05-21 03:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-64\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 537. [5a267430] 2026-05-21 03:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-64\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 538. [61bf12b2] 2026-05-21 03:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-64\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 539. [660aff42] 2026-05-21 03:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-64\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 540. [802f3f50] 2026-05-21 03:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-64\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 541. [a1005835] 2026-05-21 03:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-64\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 542. [c1e4505c] 2026-05-21 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-62\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 543. [42369bfe] 2026-05-21 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-62\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 544. [fc1e5e06] 2026-05-21 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-62\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 545. [5005e5de] 2026-05-21 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-62\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 546. [039be48a] 2026-05-21 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-62\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 547. [469f9ed7] 2026-05-21 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-62\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 548. [58cfe862] 2026-05-21 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-62\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 549. [fc93e2a3] 2026-05-21 02:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-61\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 550. [df66e185] 2026-05-21 02:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-61\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 551. [2994bc2a] 2026-05-21 02:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-61\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 552. [b48efcba] 2026-05-21 02:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-61\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 553. [54c1b1ef] 2026-05-21 02:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-61\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 554. [a0e6cc71] 2026-05-21 02:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-61\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 555. [67dcecb3] 2026-05-21 02:04 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-61\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 556. [ffcad4e8] 2026-05-20 16:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-60\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 557. [c9c31f20] 2026-05-20 16:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-60\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 558. [f71bdcb9] 2026-05-20 16:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-60\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 559. [6d4f5e45] 2026-05-20 16:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-60\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 560. [6d30c69e] 2026-05-20 16:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-60\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 561. [b9528221] 2026-05-20 16:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-60\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 562. [cd0b2f55] 2026-05-20 16:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-60\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 563. [9cd592a8] 2026-05-20 16:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-59\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 564. [6df6db70] 2026-05-20 16:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-59\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 565. [3078a2e5] 2026-05-20 16:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-59\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 566. [36963e0c] 2026-05-20 16:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-59\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 567. [e33311e9] 2026-05-20 16:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-59\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 568. [c1a5e21b] 2026-05-20 16:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-59\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 569. [9b29fa53] 2026-05-20 16:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-59\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 570. [14bbda1d] 2026-05-20 06:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 571. [9294bedb] 2026-05-20 06:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 572. [ad68147c] 2026-05-20 06:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 573. [f4e88a54] 2026-05-20 06:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 574. [f6a9f331] 2026-05-20 06:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 575. [e0bd5fea] 2026-05-20 06:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 576. [cf2f8aa0] 2026-05-20 06:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-58\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 577. [118d5b08] 2026-05-20 06:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-57\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 578. [3ed12599] 2026-05-20 06:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-57\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 579. [544d165c] 2026-05-20 06:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-57\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 580. [79b06332] 2026-05-20 06:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-57\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 581. [35ba930c] 2026-05-20 06:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-57\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 582. [2c675c0a] 2026-05-20 06:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-57\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 583. [49aaffb8] 2026-05-20 06:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-57\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 584. [2ae64bdd] 2026-05-20 06:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 585. [f0a4a074] 2026-05-20 06:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 586. [76e09768] 2026-05-20 06:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 587. [2718847a] 2026-05-20 06:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 588. [ecbecaa2] 2026-05-20 06:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 589. [7db8cce6] 2026-05-20 06:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 590. [bdae1fb4] 2026-05-20 06:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-56\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 591. [40624bce] 2026-05-20 05:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 592. [9738e4a8] 2026-05-20 05:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 593. [5ac6f975] 2026-05-20 05:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 594. [a3df2b9e] 2026-05-20 05:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 595. [f470243c] 2026-05-20 05:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 596. [c8b53ddc] 2026-05-20 05:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 597. [d2406809] 2026-05-20 05:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-55\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 598. [fdee218d] 2026-05-20 05:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-54\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 599. [b2af03dc] 2026-05-20 05:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-54\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 600. [08c9d238] 2026-05-20 05:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-54\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 601. [53bd3472] 2026-05-20 05:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-54\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 602. [11af95fc] 2026-05-20 05:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-54\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 603. [9fb03ce3] 2026-05-20 05:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-54\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 604. [0a975a34] 2026-05-20 05:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-54\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 605. [1a976317] 2026-05-20 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-53\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 606. [efb83310] 2026-05-20 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-53\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 607. [a66a3137] 2026-05-20 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-53\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 608. [c8c66827] 2026-05-20 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-53\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 609. [2ba6ef2f] 2026-05-20 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-53\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 610. [b1b0b5d1] 2026-05-20 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-53\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 611. [06aa7bd3] 2026-05-20 05:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-53\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 612. [08f732d7] 2026-05-20 05:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-52\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 613. [eaac5a6a] 2026-05-20 05:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-52\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 614. [2232c7cc] 2026-05-20 05:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-52\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 615. [5e3871c4] 2026-05-20 05:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-52\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 616. [bafbe191] 2026-05-20 05:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-52\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 617. [10175eb0] 2026-05-20 05:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-52\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 618. [ff58c2cd] 2026-05-20 05:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-52\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 619. [cb2100ba] 2026-05-20 03:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-51\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 620. [9cd8ce22] 2026-05-20 03:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-51\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 621. [5b6bd8b2] 2026-05-20 03:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-51\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 622. [5ebf3b5b] 2026-05-20 03:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-51\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 623. [45be8643] 2026-05-20 03:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-51\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 624. [3df7a632] 2026-05-20 03:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-51\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 625. [26e82c5e] 2026-05-20 03:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-51\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 626. [d113cc37] 2026-05-20 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 627. [d85dff95] 2026-05-20 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 628. [7e287b3e] 2026-05-20 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 629. [de918801] 2026-05-20 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 630. [45f32cd4] 2026-05-20 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 631. [a634d2a0] 2026-05-20 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 632. [c1868951] 2026-05-20 03:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-50\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 633. [d6db5758] 2026-05-20 03:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-48\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 634. [57ad8d59] 2026-05-20 03:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-48\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 635. [27195d1e] 2026-05-20 03:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-48\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 636. [815d3a9b] 2026-05-20 03:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-48\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 637. [7892fb7f] 2026-05-20 03:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-48\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 638. [f0bd04a7] 2026-05-20 03:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-48\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 639. [453d3edd] 2026-05-20 03:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-48\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 640. [188e74a9] 2026-05-20 03:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 641. [74b5f574] 2026-05-20 03:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 642. [a755a61a] 2026-05-20 03:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 643. [17ae2058] 2026-05-20 03:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 644. [fb2c5403] 2026-05-20 03:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 645. [cc7e6fdc] 2026-05-20 03:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 646. [63210ca9] 2026-05-20 03:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-47\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 647. [9924de5c] 2026-05-20 03:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-46\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 648. [57246439] 2026-05-20 03:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-46\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 649. [e9678e69] 2026-05-20 03:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-46\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 650. [6a8c2237] 2026-05-20 03:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-46\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 651. [2c26b2ee] 2026-05-20 03:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-46\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 652. [5382e414] 2026-05-20 03:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-46\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 653. [47f9a436] 2026-05-20 03:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-46\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 654. [de7dca53] 2026-05-20 02:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-45\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 655. [0bbebb40] 2026-05-20 02:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-45\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 656. [eabcc077] 2026-05-20 02:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-45\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 657. [e1bb23a4] 2026-05-20 02:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-45\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 658. [febb0897] 2026-05-20 02:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-45\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 659. [6f6a875b] 2026-05-20 02:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-45\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 660. [acf76cac] 2026-05-20 02:56 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-45\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 661. [9ebc8262] 2026-05-20 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-44\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 662. [86ff6c2b] 2026-05-20 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-44\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 663. [47308587] 2026-05-20 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-44\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 664. [1f09a9e4] 2026-05-20 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-44\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 665. [c4bb36a4] 2026-05-20 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-44\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 666. [0e38d978] 2026-05-20 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-44\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 667. [a7241338] 2026-05-20 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-44\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 668. [a73cbf39] 2026-05-20 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-43\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 669. [2741575b] 2026-05-20 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-43\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 670. [7f94609e] 2026-05-20 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-43\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 671. [125c5304] 2026-05-20 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-43\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 672. [249f0b3f] 2026-05-20 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-43\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 673. [971c9d89] 2026-05-20 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-43\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 674. [62917f82] 2026-05-20 02:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-43\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 675. [596ba4be] 2026-05-20 02:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-42\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 676. [31192524] 2026-05-20 02:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-42\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 677. [afedeaa2] 2026-05-20 02:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-42\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 678. [7e15ecc5] 2026-05-20 02:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-42\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 679. [10ec9f29] 2026-05-20 02:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-42\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 680. [3fc4576f] 2026-05-20 02:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-42\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 681. [ce3e7f38] 2026-05-20 02:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-42\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 682. [7a71ac46] 2026-05-20 01:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-41\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 683. [6dd44f17] 2026-05-20 01:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-41\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 684. [e8559eca] 2026-05-20 01:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-41\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 685. [f76aa30f] 2026-05-20 01:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-41\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 686. [2d68d4bb] 2026-05-20 01:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-41\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 687. [28f5d655] 2026-05-20 01:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-41\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 688. [62ebc906] 2026-05-20 01:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-41\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 689. [100b4d0e] 2026-05-20 01:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-40\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 690. [8d5270f1] 2026-05-20 01:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-40\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 691. [70f43ce8] 2026-05-20 01:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-40\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 692. [525b33ac] 2026-05-20 01:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-40\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 693. [9f527815] 2026-05-20 01:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-40\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 694. [6a3f8794] 2026-05-20 01:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-40\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 695. [95118ae5] 2026-05-20 01:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-40\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 696. [7ad80a5f] 2026-05-20 01:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-39\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 697. [01c3b506] 2026-05-20 01:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-39\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 698. [2a8d9b05] 2026-05-20 01:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-39\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 699. [5e041fef] 2026-05-20 01:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-39\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 700. [0753fe4c] 2026-05-20 01:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-39\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 701. [09a88451] 2026-05-20 01:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-39\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 702. [e7fa2a11] 2026-05-20 01:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-39\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 703. [9887dcb3] 2026-05-20 00:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-38\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 704. [6ba72938] 2026-05-20 00:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-38\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 705. [fb5dcdd9] 2026-05-20 00:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-38\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 706. [4c216bcd] 2026-05-20 00:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-38\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 707. [10565112] 2026-05-20 00:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-38\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 708. [8ffeabf6] 2026-05-20 00:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-38\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 709. [a9a41fe0] 2026-05-20 00:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-38\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 710. [0ed09ccd] 2026-05-20 00:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-37\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 711. [936f5bff] 2026-05-20 00:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-37\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 712. [c1919d1d] 2026-05-20 00:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-37\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 713. [6f10a62d] 2026-05-20 00:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-37\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 714. [fdc0e2de] 2026-05-20 00:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-37\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 715. [6a1925ef] 2026-05-20 00:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-37\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 716. [4378cab4] 2026-05-20 00:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-37\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 717. [4f94cdc6] 2026-05-20 00:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-36\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 718. [317a066c] 2026-05-20 00:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-36\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 719. [a1ff9a96] 2026-05-20 00:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-36\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 720. [ccfbc267] 2026-05-20 00:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-36\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 721. [9e777779] 2026-05-20 00:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-36\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 722. [49cf1056] 2026-05-20 00:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-36\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 723. [3ad66c5f] 2026-05-20 00:17 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-36\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 724. [9e4eb3d9] 2026-05-20 00:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-35\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 725. [712b67e3] 2026-05-20 00:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-35\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 726. [d2356204] 2026-05-20 00:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-35\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 727. [14a9bf56] 2026-05-20 00:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-35\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 728. [5b60fc17] 2026-05-20 00:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-35\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 729. [b146b4b2] 2026-05-20 00:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-35\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 730. [d32c0473] 2026-05-20 00:15 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-35\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 731. [15981a7d] 2026-05-19 12:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-34\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 732. [0755f82c] 2026-05-19 12:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-34\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 733. [5358742f] 2026-05-19 12:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-34\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 734. [55330702] 2026-05-19 12:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-34\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 735. [32df199b] 2026-05-19 12:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-34\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 736. [b1bf4756] 2026-05-19 12:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-34\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 737. [ddfaea6e] 2026-05-19 12:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-34\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 738. [5b3ca58b] 2026-05-19 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 739. [fd8fef95] 2026-05-19 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 740. [30fa1e6f] 2026-05-19 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 741. [77b812c8] 2026-05-19 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 742. [3905eb42] 2026-05-19 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 743. [0d76f9c5] 2026-05-19 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 744. [bcf1f35e] 2026-05-19 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-33\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 745. [7ccccab3] 2026-05-19 12:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-32\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 746. [948808fb] 2026-05-19 12:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-32\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 747. [4a03ca15] 2026-05-19 12:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-32\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 748. [dd7289f2] 2026-05-19 12:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-32\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 749. [e5a27c06] 2026-05-19 12:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-32\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 750. [f28499fc] 2026-05-19 12:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-32\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 751. [83c168c9] 2026-05-19 12:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-32\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 752. [94eaa9de] 2026-05-19 11:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-31\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 753. [cea3a7eb] 2026-05-19 11:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-31\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 754. [264fa594] 2026-05-19 11:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-31\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 755. [1ea61f0c] 2026-05-19 11:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-31\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 756. [7e569f6e] 2026-05-19 11:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-31\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 757. [e83fadcf] 2026-05-19 11:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-31\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 758. [e9cbab09] 2026-05-19 11:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-31\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 759. [df505350] 2026-05-19 11:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-30\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 760. [4b618409] 2026-05-19 11:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-30\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 761. [1ebf732f] 2026-05-19 11:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-30\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 762. [4e0d9a3f] 2026-05-19 11:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-30\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 763. [dc101e00] 2026-05-19 11:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-30\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 764. [65d7ad9c] 2026-05-19 11:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-30\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 765. [4eac7a16] 2026-05-19 11:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-30\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 766. [06250566] 2026-05-19 11:38 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-29\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 767. [d358f537] 2026-05-19 11:38 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-29\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 768. [9a6ce556] 2026-05-19 11:38 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-29\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 769. [f4ea8e38] 2026-05-19 11:38 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-29\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 770. [7a7f5908] 2026-05-19 11:38 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-29\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 771. [08920b5a] 2026-05-19 11:38 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-29\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 772. [e3ee9926] 2026-05-19 11:38 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-29\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 773. [7093fdb0] 2026-05-19 11:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-28\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 774. [06b8cc32] 2026-05-19 11:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-28\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 775. [1ffac37d] 2026-05-19 11:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-28\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 776. [6000aa13] 2026-05-19 11:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-28\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 777. [6d57b4d8] 2026-05-19 11:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-28\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 778. [50c15c19] 2026-05-19 11:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-28\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 779. [19170128] 2026-05-19 11:34 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-28\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 780. [3be8cc34] 2026-05-19 11:20 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-27\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 781. [798c170e] 2026-05-19 11:20 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-27\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 782. [f6b8305f] 2026-05-19 11:20 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-27\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 783. [c1405fc8] 2026-05-19 11:20 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-27\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 784. [37933960] 2026-05-19 11:20 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-27\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 785. [86239189] 2026-05-19 11:20 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-27\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 786. [f886b92a] 2026-05-19 11:20 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-27\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 787. [01743c22] 2026-05-19 09:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-26\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 788. [372f3cdb] 2026-05-19 09:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-26\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 789. [ce08db07] 2026-05-19 09:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-26\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 790. [d121dfae] 2026-05-19 09:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-26\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 791. [19bb6e0f] 2026-05-19 09:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-26\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 792. [cf9e250e] 2026-05-19 09:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-26\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 793. [43b9c6ae] 2026-05-19 09:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-26\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 794. [88e3a041] 2026-05-19 09:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-25\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 795. [8db141e0] 2026-05-19 09:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-25\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 796. [1da619d3] 2026-05-19 09:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-25\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 797. [4665fbc4] 2026-05-19 09:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-25\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 798. [edd89dfd] 2026-05-19 09:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-25\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 799. [1609b6bc] 2026-05-19 09:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-25\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 800. [4fed088d] 2026-05-19 09:16 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-25\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 801. [6e99068b] 2026-05-19 08:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-24\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 802. [bd8a52a3] 2026-05-19 08:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-24\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 803. [4fba8c95] 2026-05-19 08:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-24\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 804. [3dded161] 2026-05-19 08:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-24\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 805. [778e0c6f] 2026-05-19 08:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-24\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 806. [f7a1f6f2] 2026-05-19 08:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-24\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 807. [aa0a6b93] 2026-05-19 08:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-24\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 808. [b13c317d] 2026-05-19 07:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-23\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 809. [04f79cba] 2026-05-19 07:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-23\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 810. [a54a2dc4] 2026-05-19 07:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-23\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 811. [959bbdda] 2026-05-19 07:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-23\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 812. [bdca64d8] 2026-05-19 07:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-23\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 813. [843ba81d] 2026-05-19 07:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-23\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 814. [a1dcbb54] 2026-05-19 07:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-23\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 815. [b2e373b0] 2026-05-19 07:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-22\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 816. [72144326] 2026-05-19 07:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-22\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 817. [5b3716ed] 2026-05-19 07:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-22\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 818. [229acd8a] 2026-05-19 07:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-22\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 819. [5579293e] 2026-05-19 07:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-22\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 820. [68de6a9b] 2026-05-19 07:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-22\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 821. [7b93c2f5] 2026-05-19 07:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-22\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 822. [f176ae4e] 2026-05-19 07:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-21\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 823. [69eb9bfd] 2026-05-19 07:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-21\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 824. [fe4f7ad2] 2026-05-19 07:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-21\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 825. [51888bad] 2026-05-19 07:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-21\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 826. [984425e5] 2026-05-19 07:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-21\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 827. [04bdaaaf] 2026-05-19 07:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-21\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 828. [e421a5b3] 2026-05-19 07:42 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-21\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 829. [1fb3c941] 2026-05-19 04:23 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-20\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 830. [67a6f314] 2026-05-19 04:23 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-20\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 831. [91c89a80] 2026-05-19 04:23 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-20\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 832. [ae8f32f9] 2026-05-19 04:23 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-20\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 833. [6db978eb] 2026-05-19 04:23 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-20\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 834. [c8a61d3e] 2026-05-19 04:23 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-20\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 835. [a8400bf5] 2026-05-19 04:23 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-20\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 836. [06aef5ea] 2026-05-19 03:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-19\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 837. [e4604aa1] 2026-05-19 03:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-19\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 838. [a969c3ce] 2026-05-19 03:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-19\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 839. [ac9cb005] 2026-05-19 03:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-19\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 840. [ee9099c1] 2026-05-19 03:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-19\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 841. [02eac718] 2026-05-19 03:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-19\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 842. [4d7be3d6] 2026-05-19 03:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-19\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 843. [4fffd64b] 2026-05-19 03:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-18\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 844. [65ae9c30] 2026-05-19 03:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-18\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 845. [f4878cf4] 2026-05-19 03:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-18\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 846. [d9b690c7] 2026-05-19 03:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-18\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 847. [8c0b523f] 2026-05-19 03:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-18\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 848. [0a902842] 2026-05-19 03:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-18\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 849. [cdc689c6] 2026-05-19 03:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-18\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 850. [33b49482] 2026-05-19 03:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-17\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 851. [6b20e447] 2026-05-19 03:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-17\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 852. [747498dc] 2026-05-19 03:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-17\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 853. [6153c664] 2026-05-19 03:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-17\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 854. [05deb4fa] 2026-05-19 03:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-17\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 855. [01e0b78d] 2026-05-19 03:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-17\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 856. [fe0f8163] 2026-05-19 03:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-17\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 857. [fb3e049d] 2026-05-19 03:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-16\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 858. [ef9f598a] 2026-05-19 03:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-16\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 859. [f5bc373d] 2026-05-19 03:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-16\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 860. [227155a4] 2026-05-19 03:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-16\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 861. [409056bd] 2026-05-19 03:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-16\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 862. [3b6962d6] 2026-05-19 03:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-16\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 863. [d17b2d76] 2026-05-19 03:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-16\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 864. [d5f8be2e] 2026-05-19 03:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 865. [b913b522] 2026-05-19 03:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 866. [6eb40045] 2026-05-19 03:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 867. [0ad6d31f] 2026-05-19 03:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 868. [6e4ec56d] 2026-05-19 03:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 869. [330dbe39] 2026-05-19 03:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 870. [80464439] 2026-05-19 03:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-15\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 871. [ca72ed2c] 2026-05-19 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-14\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 872. [4c645692] 2026-05-19 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-14\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 873. [9da5f5c3] 2026-05-19 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-14\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 874. [1e784ce3] 2026-05-19 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-14\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 875. [79dd2e8b] 2026-05-19 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-14\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 876. [4b479c3d] 2026-05-19 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-14\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 877. [d1c54d2f] 2026-05-19 02:33 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-14\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 878. [2ea078ad] 2026-05-19 02:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 879. [365876da] 2026-05-19 02:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 880. [d92ae573] 2026-05-19 02:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 881. [16712159] 2026-05-19 02:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 882. [59d358a6] 2026-05-19 02:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 883. [8dd66bb3] 2026-05-19 02:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 884. [cb49f19b] 2026-05-19 02:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-13\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 885. [7f4d30be] 2026-05-19 02:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-12\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 886. [ab46d2e2] 2026-05-19 02:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-12\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 887. [b2584701] 2026-05-19 02:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-12\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 888. [f1fd8fba] 2026-05-19 02:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-12\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 889. [1cf5818c] 2026-05-19 02:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-12\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 890. [8958ad63] 2026-05-19 02:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-12\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 891. [06248dd4] 2026-05-19 02:13 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-12\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 892. [0c0e46ec] 2026-05-19 02:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 893. [8396fd04] 2026-05-19 02:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 894. [adec2e83] 2026-05-19 02:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 895. [dc33f660] 2026-05-19 02:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 896. [09e256ef] 2026-05-19 02:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 897. [f44b542b] 2026-05-19 02:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 898. [69162d06] 2026-05-19 02:02 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-11\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 899. [f76c5cea] 2026-05-19 01:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-10\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 900. [f2e3b580] 2026-05-19 01:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-10\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 901. [73a39997] 2026-05-19 01:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-10\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 902. [f3885b2c] 2026-05-19 01:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-10\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 903. [6e8c3097] 2026-05-19 01:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-10\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 904. [74f14f37] 2026-05-19 01:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-10\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 905. [f110ffb7] 2026-05-19 01:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-10\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 906. [29c86a42] 2026-05-19 00:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 907. [10e810bd] 2026-05-19 00:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 908. [19d332ea] 2026-05-19 00:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 909. [89dc2fc6] 2026-05-19 00:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 910. [6847b422] 2026-05-19 00:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 911. [cf5821b5] 2026-05-19 00:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 912. [b2b26eda] 2026-05-19 00:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-9\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 913. [37f7c14d] 2026-05-19 00:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-8\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 914. [2732cdcd] 2026-05-19 00:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-8\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 915. [9eef2eeb] 2026-05-19 00:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-8\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 916. [dc77b9c6] 2026-05-19 00:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-8\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 917. [33f69347] 2026-05-19 00:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-8\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 918. [d2d49e54] 2026-05-19 00:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-8\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 919. [e5f11a39] 2026-05-19 00:08 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-8\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 920. [a4caedd4] 2026-05-18 16:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-7\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 921. [27d0626a] 2026-05-18 16:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-7\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 922. [d87a5dfb] 2026-05-18 16:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-7\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 923. [ee227123] 2026-05-18 16:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-7\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 924. [f3aa474c] 2026-05-18 16:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-7\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 925. [bcb58773] 2026-05-18 16:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-7\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 926. [d47b6ec2] 2026-05-18 16:58 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-7\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 927. [5b0d920f] 2026-05-18 16:47 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 928. [7fc42af4] 2026-05-18 16:47 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 929. [c20f7abb] 2026-05-18 16:47 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 930. [029b9369] 2026-05-18 16:47 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 931. [87e2a200] 2026-05-18 16:47 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 932. [9532c8a4] 2026-05-18 16:47 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 933. [222507d2] 2026-05-18 16:47 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-6\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 934. [a2ff0876] 2026-05-18 16:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 935. [655e1d0b] 2026-05-18 16:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 936. [b7341dab] 2026-05-18 16:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 937. [a4b24b95] 2026-05-18 16:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 938. [fbb7a79f] 2026-05-18 16:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 939. [f7a94b80] 2026-05-18 16:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 940. [01a268c3] 2026-05-18 16:44 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-5\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 941. [d11f3884] 2026-05-18 16:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-4\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 942. [1f3b6f60] 2026-05-18 16:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-4\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 943. [8d37d88b] 2026-05-18 16:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-4\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 944. [797e4e15] 2026-05-18 16:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-4\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 945. [1deb6ea4] 2026-05-18 16:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-4\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 946. [4ea2612c] 2026-05-18 16:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-4\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 947. [0e42659b] 2026-05-18 16:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-4\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 948. [e8fb180d] 2026-05-18 16:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 949. [04da4ccb] 2026-05-18 16:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 950. [7fa946f3] 2026-05-18 16:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 951. [e05e3f81] 2026-05-18 16:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 952. [d62b2210] 2026-05-18 16:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 953. [2fb59b6b] 2026-05-18 16:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 954. [5a5149e8] 2026-05-18 16:35 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-3\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 955. [4f25dbc1] 2026-05-18 16:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-2\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 956. [704cb9b8] 2026-05-18 16:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-2\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 957. [a95ad7d9] 2026-05-18 16:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-2\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 958. [2a83e5e8] 2026-05-18 16:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-2\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 959. [015c5705] 2026-05-18 16:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-2\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 960. [f5908a80] 2026-05-18 16:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-2\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 961. [52f6102f] 2026-05-18 16:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-2\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 962. [8782934d] 2026-05-18 16:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 963. [68d49df4] 2026-05-18 16:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 964. [0ec61410] 2026-05-18 16:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 965. [6a6aa229] 2026-05-18 16:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 966. [99a39a4d] 2026-05-18 16:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 967. [13bf5538] 2026-05-18 16:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 968. [29babc92] 2026-05-18 16:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-1\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 969. [fd9c8396] 2026-05-18 16:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 970. [3571de78] 2026-05-18 16:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 971. [b9b1c176] 2026-05-18 16:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 972. [51074766] 2026-05-18 16:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 973. [99748ed6] 2026-05-18 16:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 974. [3b0fa7b6] 2026-05-18 16:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 975. [1d271771] 2026-05-18 16:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-0\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 976. [0f690e96] 2026-05-18 14:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-326\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 977. [ca93b6c5] 2026-05-18 14:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-326\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 978. [b301febb] 2026-05-18 14:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-326\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 979. [c9217091] 2026-05-18 14:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-326\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 980. [1dba5b1a] 2026-05-18 14:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-326\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 981. [0fa483a7] 2026-05-18 14:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-326\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 982. [8979872f] 2026-05-18 14:40 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-326\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 983. [c0d9ccbb] 2026-05-18 14:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-325\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 984. [dbc3d1c7] 2026-05-18 14:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-325\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 985. [04ef78f6] 2026-05-18 14:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-325\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 986. [17fc489b] 2026-05-18 14:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-325\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 987. [a1096f9c] 2026-05-18 14:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-325\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 988. [1c552aa9] 2026-05-18 14:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-325\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 989. [49eb7a61] 2026-05-18 14:09 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-325\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 990. [819ff984] 2026-05-18 06:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-311\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 991. [a00c3f01] 2026-05-18 06:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-311\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 992. [e81828f7] 2026-05-18 06:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-311\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 993. [52de51c7] 2026-05-18 06:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-311\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 994. [8320ce78] 2026-05-18 06:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-311\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 995. [3c8b5633] 2026-05-18 06:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-311\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 996. [f94e10e4] 2026-05-18 06:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-311\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 997. [806b6f09] 2026-05-18 06:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-309\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 998. [1a0cb84e] 2026-05-18 06:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-309\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 999. [12c7de6c] 2026-05-18 06:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-309\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1000. [632dc56f] 2026-05-18 06:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-309\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1001. [3845f6ac] 2026-05-18 06:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-309\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1002. [f42662a6] 2026-05-18 06:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-309\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1003. [01b5873f] 2026-05-18 06:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-309\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1004. [1cef16a8] 2026-05-18 06:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-307\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1005. [7a9670f0] 2026-05-18 06:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-307\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1006. [da9fd41f] 2026-05-18 06:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-307\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1007. [2989665a] 2026-05-18 06:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-307\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1008. [9722a196] 2026-05-18 06:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-307\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1009. [282d50ae] 2026-05-18 06:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-307\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1010. [dd072df7] 2026-05-18 06:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-307\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1011. [59a2d0b7] 2026-05-18 02:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-303\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1012. [13a7a12f] 2026-05-18 02:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-303\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1013. [c3bfb2c9] 2026-05-18 02:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-303\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1014. [a1f62791] 2026-05-18 02:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-303\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1015. [559d573c] 2026-05-18 02:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-303\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1016. [ebb556f7] 2026-05-18 02:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-303\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1017. [7975fbcd] 2026-05-18 02:59 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-303\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1018. [c6e818dc] 2026-05-18 02:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-301\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1019. [1f4032bf] 2026-05-18 02:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-301\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1020. [9ce2c692] 2026-05-18 02:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-301\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1021. [70b1c326] 2026-05-18 02:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-301\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1022. [1cb1768b] 2026-05-18 02:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-301\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1023. [7fe60df8] 2026-05-18 02:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-301\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1024. [0fa2fe95] 2026-05-18 02:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-301\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1025. [a3b7e404] 2026-05-18 01:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-293\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1026. [d53e2b04] 2026-05-18 01:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-293\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1027. [1d326f27] 2026-05-18 01:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-293\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1028. [aee80130] 2026-05-18 01:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-293\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1029. [f5ff8c3b] 2026-05-18 01:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-293\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1030. [a154b6c1] 2026-05-18 01:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-293\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1031. [19218ab0] 2026-05-18 01:48 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-293\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1032. [5e14b777] 2026-05-17 16:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-285\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1033. [4ea6eb7d] 2026-05-17 16:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-285\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1034. [7b0946e7] 2026-05-17 16:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-285\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1035. [c745a977] 2026-05-17 16:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-285\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1036. [f05cb536] 2026-05-17 16:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-285\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1037. [bbfa77b2] 2026-05-17 16:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-285\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1038. [f42a25bc] 2026-05-17 16:01 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-285\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1039. [d2b089ed] 2026-05-17 15:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-283\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1040. [4b9f7d94] 2026-05-17 15:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-283\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1041. [b172168d] 2026-05-17 15:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-283\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1042. [f9732d0f] 2026-05-17 15:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-283\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1043. [a94530a5] 2026-05-17 15:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-283\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1044. [1431f5a9] 2026-05-17 15:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-283\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1045. [accbe326] 2026-05-17 15:55 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-283\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1046. [9b008898] 2026-05-17 15:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-281\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1047. [7199ca43] 2026-05-17 15:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-281\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1048. [b2cab0c1] 2026-05-17 15:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-281\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1049. [9874754f] 2026-05-17 15:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-281\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1050. [b0ca4d48] 2026-05-17 15:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-281\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1051. [e7fa7bf6] 2026-05-17 15:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-281\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1052. [a234f67a] 2026-05-17 15:49 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-281\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1053. [3d3fd0fa] 2026-05-17 15:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-280\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1054. [bcecd085] 2026-05-17 15:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-280\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1055. [61bd67a5] 2026-05-17 15:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-280\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1056. [a3351e69] 2026-05-17 15:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-280\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1057. [ddd95f0d] 2026-05-17 15:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-280\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1058. [9be098a4] 2026-05-17 15:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-280\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1059. [8ddce515] 2026-05-17 15:43 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-280\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1060. [0ca5d190] 2026-05-17 15:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-278\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1061. [8b5354b1] 2026-05-17 15:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-278\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1062. [e7ff13e2] 2026-05-17 15:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-278\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1063. [330c3a07] 2026-05-17 15:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-278\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1064. [80de23c1] 2026-05-17 15:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-278\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1065. [76e0d96b] 2026-05-17 15:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-278\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1066. [8866859d] 2026-05-17 15:30 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-278\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1067. [113a4acf] 2026-05-17 15:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-275\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1068. [7d7db41d] 2026-05-17 15:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-275\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1069. [336afc4e] 2026-05-17 15:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-275\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1070. [b670e513] 2026-05-17 15:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-275\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1071. [391a163a] 2026-05-17 15:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-275\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1072. [a79fbf9b] 2026-05-17 15:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-275\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1073. [ffc5fb34] 2026-05-17 15:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-275\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1074. [674e3ee9] 2026-05-17 15:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-273\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1075. [32cfe2b2] 2026-05-17 15:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-273\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1076. [322babe6] 2026-05-17 15:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-273\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1077. [d7712fba] 2026-05-17 15:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-273\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1078. [8dc8dbaf] 2026-05-17 15:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-273\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1079. [ab91f8fb] 2026-05-17 15:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-273\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1080. [1244ef6d] 2026-05-17 15:18 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-273\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1081. [d902b29a] 2026-05-17 15:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-270\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1082. [4ced8886] 2026-05-17 15:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-270\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1083. [aa021040] 2026-05-17 15:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-270\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1084. [e1c25014] 2026-05-17 15:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-270\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1085. [83493594] 2026-05-17 15:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-270\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1086. [b341cb5a] 2026-05-17 15:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-270\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1087. [f93626e1] 2026-05-17 15:14 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-270\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1088. [7efc7c39] 2026-05-17 15:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-266\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1089. [25e5b9d9] 2026-05-17 15:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-266\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1090. [26a30328] 2026-05-17 15:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-266\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1091. [043cebaf] 2026-05-17 15:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-266\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1092. [fae2fc67] 2026-05-17 15:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-266\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1093. [ef46eabc] 2026-05-17 15:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-266\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1094. [880cdbe0] 2026-05-17 15:06 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-266\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1095. [a96087aa] 2026-05-17 14:29 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-263\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1096. [75ec5ee8] 2026-05-17 14:29 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-263\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1097. [b8362eb9] 2026-05-17 14:29 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-263\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1098. [043eb48a] 2026-05-17 14:29 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-263\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1099. [9bd061c1] 2026-05-17 14:29 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-263\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1100. [dbf68ece] 2026-05-17 14:29 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-263\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1101. [eb17e724] 2026-05-17 14:29 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-263\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1102. [cd151546] 2026-05-17 14:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-261\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1103. [a4118f56] 2026-05-17 14:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-261\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1104. [2a8dde8b] 2026-05-17 14:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-261\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1105. [c9434cf6] 2026-05-17 14:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-261\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1106. [7f9f3ea6] 2026-05-17 14:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-261\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1107. [b74a6107] 2026-05-17 14:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-261\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1108. [7130cd61] 2026-05-17 14:25 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-261\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1109. [3c04cb7b] 2026-05-17 13:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-259\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1110. [6404b76e] 2026-05-17 13:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-259\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1111. [57678827] 2026-05-17 13:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-259\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1112. [b1deceeb] 2026-05-17 13:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-259\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1113. [91a60e0e] 2026-05-17 13:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-259\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1114. [7ddd3054] 2026-05-17 13:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-259\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1115. [c2048d32] 2026-05-17 13:57 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-259\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1116. [8107ccd0] 2026-05-17 10:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-258\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1117. [06af8b56] 2026-05-17 10:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-258\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1118. [ce0efc96] 2026-05-17 10:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-258\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1119. [8b39aafb] 2026-05-17 10:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-258\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1120. [a5480f9b] 2026-05-17 10:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-258\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1121. [99fb0d6e] 2026-05-17 10:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-258\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1122. [4651058b] 2026-05-17 10:05 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-258\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1123. [c74971b0] 2026-05-17 01:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-250\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1124. [54af641c] 2026-05-17 01:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-250\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1125. [e0c6bffa] 2026-05-17 01:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-250\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1126. [1e7f1083] 2026-05-17 01:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-250\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1127. [13341378] 2026-05-17 01:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-250\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1128. [ff0cf381] 2026-05-17 01:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-250\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1129. [b1fc6d74] 2026-05-17 01:24 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-250\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1130. [52efadb3] 2026-05-16 16:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-249\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1131. [8752eb2d] 2026-05-16 16:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-249\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1132. [5d898c73] 2026-05-16 16:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-249\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1133. [634a93bf] 2026-05-16 16:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-249\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1134. [b6825b26] 2026-05-16 16:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-249\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1135. [d4e36110] 2026-05-16 16:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-249\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1136. [420819ba] 2026-05-16 16:53 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-249\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1137. [7ff971fc] 2026-05-16 16:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-248\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1138. [738a18e9] 2026-05-16 16:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-248\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1139. [b2fbbf98] 2026-05-16 16:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-248\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1140. [749c36ee] 2026-05-16 16:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-248\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1141. [2719bacf] 2026-05-16 16:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-248\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1142. [68fba70d] 2026-05-16 16:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-248\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1143. [caecc0d5] 2026-05-16 16:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-248\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1144. [2e79ad4f] 2026-05-16 09:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-247\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1145. [78976cd4] 2026-05-16 09:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-247\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1146. [b740e0cd] 2026-05-16 09:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-247\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1147. [ad7088e5] 2026-05-16 09:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-247\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1148. [6085930a] 2026-05-16 09:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-247\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1149. [6979a629] 2026-05-16 09:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-247\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1150. [b9b63b99] 2026-05-16 09:19 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-247\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1151. [798f88f7] 2026-05-16 02:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-238\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1152. [32350402] 2026-05-16 02:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-238\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1153. [bfaf3a23] 2026-05-16 02:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-238\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1154. [db8b6d32] 2026-05-16 02:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-238\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1155. [8e552413] 2026-05-16 02:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-238\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1156. [5337624c] 2026-05-16 02:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-238\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1157. [bba3ed02] 2026-05-16 02:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-238\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1158. [14493e1b] 2026-05-16 02:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-237\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1159. [41e3af30] 2026-05-16 02:32 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-236\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1160. [d730df6d] 2026-05-16 01:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-227\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1161. [a79f3883] 2026-05-16 01:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-227\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1162. [8af27ff1] 2026-05-16 01:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-227\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1163. [3ec9381f] 2026-05-16 01:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-227\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1164. [88610947] 2026-05-16 01:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-227\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1165. [f4218041] 2026-05-16 01:46 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-227\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1166. [e17bd942] 2026-05-16 01:45 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-226\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1167. [9f496612] 2026-05-15 12:26 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-202\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1168. [3a1431cc] 2026-05-15 12:26 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-202\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1169. [2ebd7321] 2026-05-15 12:26 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-202\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1170. [a274f643] 2026-05-15 12:26 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-202\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1171. [903d9afe] 2026-05-15 12:26 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-202\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1172. [b9c71d7d] 2026-05-15 12:26 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-202\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1173. [eeacc3f4] 2026-05-15 12:26 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-202\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1174. [8da52f1f] 2026-05-15 12:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-198\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1175. [2edb2a07] 2026-05-15 12:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-198\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1176. [64322add] 2026-05-15 12:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-198\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1177. [5c956eb8] 2026-05-15 12:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-198\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1178. [92919af8] 2026-05-15 12:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-198\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1179. [6edf0558] 2026-05-15 12:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-198\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1180. [fda86115] 2026-05-15 12:12 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-198\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1181. [b519da72] 2026-05-15 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-197\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1182. [7b866f1b] 2026-05-15 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-197\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1183. [87e42516] 2026-05-15 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-197\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1184. [bb4d7fa5] 2026-05-15 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-197\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1185. [3d2c4d64] 2026-05-15 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-197\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1186. [fe85e359] 2026-05-15 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-197\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1187. [c8c55526] 2026-05-15 12:11 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-197\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1188. [910fb8bc] 2026-05-15 12:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-196\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1189. [2b22a234] 2026-05-15 12:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-196\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1190. [66d01ae4] 2026-05-15 12:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-196\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1191. [9432eb94] 2026-05-15 12:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-196\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1192. [fc3ac586] 2026-05-15 12:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-196\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1193. [44f3bca4] 2026-05-15 12:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-196\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1194. [3a166a5b] 2026-05-15 12:10 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-196\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1195. [acbc2cd2] 2026-05-15 12:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-195\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1196. [e1de672b] 2026-05-15 12:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-195\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1197. [01036b84] 2026-05-15 12:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-195\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1198. [41432652] 2026-05-15 12:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-195\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1199. [9defc503] 2026-05-15 12:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-195\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1200. [c1c22e1f] 2026-05-15 12:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-195\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1201. [ee78011d] 2026-05-15 12:07 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-195\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1202. [116fc85e] 2026-05-15 11:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-187\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1203. [275dc179] 2026-05-15 11:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-187\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1204. [f90797f4] 2026-05-15 11:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-187\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1205. [3bcfee3d] 2026-05-15 11:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-187\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1206. [c83dc66f] 2026-05-15 11:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-187\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1207. [61eeb34e] 2026-05-15 11:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-187\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1208. [427324d9] 2026-05-15 11:37 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-187\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1209. [1f99c68a] 2026-05-15 11:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-185\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1210. [a80610a7] 2026-05-15 11:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-185\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1211. [48b8c3b0] 2026-05-15 11:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-185\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1212. [dfaa800e] 2026-05-15 11:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-185\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1213. [7f28c8a1] 2026-05-15 11:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-185\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1214. [14385f08] 2026-05-15 11:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-185\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1215. [630cf035] 2026-05-15 11:36 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-185\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1216. [c823d9bf] 2026-05-15 11:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-184\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1217. [d2fb6f9a] 2026-05-15 11:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-184\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1218. [702b0ad8] 2026-05-15 11:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-184\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1219. [fb1aba8f] 2026-05-15 11:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-184\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1220. [a341bb58] 2026-05-15 11:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-184\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1221. [d3b61fa6] 2026-05-15 11:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-184\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1222. [ca032c52] 2026-05-15 11:31 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-184\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1223. [a1470f32] 2026-05-15 11:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-183\\test_memory_plus_session_plus_0\\workspace\n Messages: 1 | First: What naming convention?\n\n 1224. [ee46ee2a] 2026-05-15 11:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-183\\test_session_with_no_memory_do0\\workspace\n Messages: 1 | First: Hello\n\n 1225. [92681d3c] 2026-05-15 11:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-183\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 2\n\n 1226. [0f5122e8] 2026-05-15 11:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-183\\test_cross_session_memory_cont0\\workspace\n Messages: 1 | First: Session 1\n\n 1227. [f40ef0fc] 2026-05-15 11:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-183\\test_memory_entries_available_0\\workspace\n Messages: 1 | First: How do we test database?\n\n 1228. [3da39362] 2026-05-15 11:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-183\\test_session_restoration_inclu0\\workspace\n Messages: 1 | First: Set up the project\n\n 1229. [f2aa3f2e] 2026-05-15 11:22 - C:\\Users\\question\\AppData\\Local\\Temp\\pytest-of-question\\pytest-183\\test_memory_persists_across_se0\\workspace\n Messages: 3 | First: What pattern should we use?\n\n 1230. [40afb007] 2026-04-05 09:33 - D:\\Desktop\\minicode\\py-src\n Messages: 0 | First: (empty)\n\n 1231. [a6cfc22e] 2026-04-05 09:03 - D:\\Desktop\\minicode\\py-src\n Messages: 5 | First: \u4f60\u597d\uff0c\u4f60\u662f\u8c01\n\nTotal: 1231 session(s)", + "stderr": "" + }, + { + "label": "inspect-session", + "command": "C:\\Users\\question\\anaconda3\\python.exe -m minicode.main --inspect-session latest", + "exit_code": 0, + "status": "passed", + "summary": "- [tool:edit_file/success] Patched demo.txt", + "stdout": "Session inspect: 2e226a0a\n Created: 2026-06-06 03:46:02\n Updated: 2026-06-06 03:46:02\n Workspace: D:\\Desktop\\minicode\\outputs\\release_smoke_workspace\n Messages: 0\n Transcript entries: 2\n History entries: 1\n Skills: (none)\n MCP servers: (none)\n Checkpoints: 1\n Runtime: phase:verify@2\n Readiness: ready via anthropic-compatible\n Instructions: 1 layer(s): project-managed\n Hooks: 1/1 hook(s) enabled\n Delegation: 1 running, 3 slot(s) open\n Extensions: 1 extension(s): git-helpers\n\nRecent checkpoints: 1 saved; latest [8cd58f4e] demo.txt\n\nInstruction layers:\n - project-managed [project/managed, present] Prefer verification-first delivery.\n\nHook surface:\n 1/1 hook(s) enabled\n\nDelegation surface:\n 1 running, 3 slot(s) open\n - lint-worker :: running\n\nExtensions:\n - git-helpers [project] 1.0.0, enabled :: Local helper bundle\n\nReadiness:\n ready via anthropic-compatible (ready)\n\nRecent transcript (2 shown):\n - [runtime:phase] Runtime phase: verify.\n - [tool:edit_file/success] Patched demo.txt", + "stderr": "" + }, + { + "label": "replay-session", + "command": "C:\\Users\\question\\anaconda3\\python.exe -m minicode.main --replay-session latest", + "exit_code": 0, + "status": "passed", + "summary": "- [tool:edit_file/success] Patched demo.txt", + "stdout": "Session replay: 2e226a0a\n Workspace: D:\\Desktop\\minicode\\outputs\\release_smoke_workspace\n Created: 2026-06-06 03:46:02\n Updated: 2026-06-06 03:46:02\n Runtime: phase:verify@2\n Checkpoints: 1\n Readiness: ready via anthropic-compatible\n Delegation: 1 running, 3 slot(s) open\n\nCheckpoint trail (1 shown):\n - [8cd58f4e] 2026-06-06 03:46:02 :: demo.txt (edit)\n\nInstruction layers:\n - project-managed [project/managed, present] Prefer verification-first delivery.\n\nExtensions:\n - git-helpers [project] 1.0.0, enabled :: Local helper bundle\n\nPrompt history (1 shown):\n 1. continue with runtime trace\n\nTranscript timeline (2 shown):\n - [runtime:phase] Runtime phase: verify.\n - [tool:edit_file/success] Patched demo.txt", + "stderr": "" + }, + { + "label": "preview-rewind", + "command": "C:\\Users\\question\\anaconda3\\python.exe -m minicode.main --preview-rewind latest", + "exit_code": 0, + "status": "passed", + "summary": "Type: edit", + "stdout": "Rewind preview for session 2e226a0a:\n\nWould restore 1 checkpoint(s) across 1 file(s).\nMode: restore pre-edit file snapshots.\n\nPlanned restores:\n 1. [8cd58f4e] 2026-06-06 03:46:02 - D:\\Desktop\\minicode\\outputs\\release_smoke_workspace\\demo.txt\n Restores: existing file\n Type: edit", + "stderr": "" + } + ], + "provider_diagnostics": [ + { + "label": "headless-smoke", + "outcome": "provider_outage", + "command": "C:\\Users\\question\\anaconda3\\python.exe -m minicode.headless Reply with exactly OK.", + "exit_code": 0, + "summary": "Provider availability failure: deepseek-v4-pro[1m] failed and all viable fallback models were unavailable. Remaining blocker is upstream provider/channel availability, not a local retry loop. Active channel: anthropic-compatible via baseUrl/authToken. Last error (RuntimeError): No available channel for model deepseek-v4-pro[1m] under group cc (distributor) (request id: 202606060346468033683028268d9d66QHAvNlc) Next step: Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken.", + "stdout": "Provider availability failure: deepseek-v4-pro[1m] failed and all viable fallback models were unavailable. Remaining blocker is upstream provider/channel availability, not a local retry loop. Active channel: anthropic-compatible via baseUrl/authToken. Last error (RuntimeError): No available channel for model deepseek-v4-pro[1m] under group cc (distributor) (request id: 202606060346468033683028268d9d66QHAvNlc) Next step: Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken.\n", + "stderr": "2026-06-06 11:46:39,514 [ERROR] minicode.agent_loop: Model API error (RuntimeError): No available channel for model deepseek-v4-pro[1m] under group cc (distributor) (request id: 202606060346468033683028268d9d66QHAvNlc)\n2026-06-06 11:46:39,514 [ERROR] minicode.model_switcher: Model fallback failed: Switch [FAILED]: deepseek-v4-pro[1m] (anthropic) -> (unknown) Errors: No viable fallback models were available\n" + } + ], + "runtime_profile_artifacts": { + "json": "D:\\Desktop\\minicode\\benchmarks\\runtime_profile_eval_results.json", + "markdown": "D:\\Desktop\\minicode\\benchmarks\\runtime_profile_eval_results.md" + }, + "readiness_report": { + "provider": "anthropic", + "provider_ready": true, + "provider_channel": "anthropic-compatible via baseUrl/authToken", + "fallback_ready": false, + "fallback_candidates": [], + "viable_fallbacks": [], + "fallback_guidance": [ + "Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken.", + "Add fallbackModels or anthropicFallbackModels to enable model failover.", + "No local fallback credentials are configured for OpenAI, OpenRouter, or custom providers." + ], + "issues": [ + "Primary provider is ready, but no configured or default fallback models are available." + ], + "summary": "readiness: warning (anthropic) [Primary provider is ready, but no configured or default fallback models are available.]" + } +} \ No newline at end of file diff --git a/benchmarks/release_readiness_results.md b/benchmarks/release_readiness_results.md new file mode 100644 index 0000000..aad2e23 --- /dev/null +++ b/benchmarks/release_readiness_results.md @@ -0,0 +1,44 @@ +# MiniCode Release Readiness + +- Generated at: 2026-06-06T03:46:02.433983+00:00 +- Status: at-risk + +## Core Gate + +| check | status | exit_code | summary | +| --- | --- | ---: | --- | +| compileall | passed | 0 | compileall completed. | +| pytest-q | passed | 0 | 1027 passed, 2 skipped, 3 warnings in 18.88s | +| runtime-profile-eval | passed | 0 | benchmarks\runtime_profile_eval_results.md | + +## Product Smokes + +| check | status | exit_code | summary | +| --- | --- | ---: | --- | +| list-sessions | passed | 0 | Total: 1231 session(s) | +| inspect-session | passed | 0 | - [tool:edit_file/success] Patched demo.txt | +| replay-session | passed | 0 | - [tool:edit_file/success] Patched demo.txt | +| preview-rewind | passed | 0 | Type: edit | + +## Provider Diagnostics + +| label | outcome | exit_code | summary | +| --- | --- | ---: | --- | +| headless-smoke | provider_outage | 0 | Provider availability failure: deepseek-v4-pro[1m] failed and all viable fallback models were unavailable. Remaining blocker is upstream provider/channel availability, not a local retry loop. Active channel: anthropic-compatible via baseUrl/authToken. Last error (RuntimeError): No available channel for model deepseek-v4-pro[1m] under group cc (distributor) (request id: 202606060346468033683028268d9d66QHAvNlc) Next step: Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken. | + +## Provider Fallback Coverage + +- Provider: anthropic +- Provider ready: yes +- Channel: anthropic-compatible via baseUrl/authToken +- Fallback ready: no +- Summary: readiness: warning (anthropic) [Primary provider is ready, but no configured or default fallback models are available.] +- Guidance: + - Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken. + - Add fallbackModels or anthropicFallbackModels to enable model failover. + - No local fallback credentials are configured for OpenAI, OpenRouter, or custom providers. + +## Runtime Profile Artifacts + +- JSON: D:\Desktop\minicode\benchmarks\runtime_profile_eval_results.json +- Markdown: D:\Desktop\minicode\benchmarks\runtime_profile_eval_results.md \ No newline at end of file diff --git a/benchmarks/runtime_profile_eval.py b/benchmarks/runtime_profile_eval.py new file mode 100644 index 0000000..93e89d9 --- /dev/null +++ b/benchmarks/runtime_profile_eval.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from minicode.runtime_profile_eval import ( + ProviderDiagnostic, + RuntimeEvalCondition, + RuntimeEvalScenario, + evaluate_runtime_profiles, + runtime_profile_eval_as_dict, + runtime_profile_eval_as_markdown, +) +from minicode.tooling import ToolRegistry +from minicode.types import AgentStep, ChatMessage, ModelAdapter + + +class ScriptedModel(ModelAdapter): + def __init__(self, steps: list[AgentStep]) -> None: + self._steps = steps + self.calls = 0 + + def next(self, messages: list[ChatMessage], on_stream_chunk=None) -> AgentStep: + step = self._steps[self.calls] + self.calls += 1 + return step + + +def build_demo_scenarios() -> list[RuntimeEvalScenario]: + return [ + RuntimeEvalScenario( + name="depth-budget-floor", + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "repair the runtime policy"}, + ], + model_factory=lambda: ScriptedModel( + [ + AgentStep( + type="assistant", + content="scanning the relevant files", + kind="progress", + ), + AgentStep(type="assistant", content="done"), + ] + ), + tools_factory=lambda: ToolRegistry([]), + max_steps=1, + ), + RuntimeEvalScenario( + name="widening-escalation", + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "repair the runtime policy"}, + ], + model_factory=lambda: ScriptedModel( + [ + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content=""), + AgentStep(type="assistant", content=""), + AgentStep(type="assistant", content=""), + AgentStep(type="assistant", content=""), + AgentStep(type="assistant", content="done with a broader plan"), + ] + ), + tools_factory=lambda: ToolRegistry([]), + ), + ] + + +def build_demo_conditions() -> list[RuntimeEvalCondition]: + return [ + RuntimeEvalCondition( + label="single", + runtime={"runtimeProfile": "single"}, + max_steps=1, + ), + RuntimeEvalCondition( + label="single-deep", + runtime={"runtimeProfile": "single-deep"}, + max_steps=1, + ), + ] + + +def _classify_provider_diagnostic( + *, + label: str, + command: str, + exit_code: int, + stdout: str, + stderr: str, +) -> ProviderDiagnostic: + combined = " ".join(f"{stdout}\n{stderr}".lower().split()) + stripped_stdout = stdout.strip() + summary_source = stripped_stdout or stderr.strip() + summary_line = summary_source.splitlines()[0].strip() if summary_source else "" + if exit_code == 0 and stripped_stdout == "OK": + outcome = "answered" + elif ( + "provider availability failure" in combined + or "all viable fallback models were unavailable" in combined + or "no available channel" in combined + ): + outcome = "provider_outage" + elif "empty response" in combined: + outcome = "empty_output" + else: + outcome = "error" + return ProviderDiagnostic( + label=label, + outcome=outcome, + command=command, + exit_code=exit_code, + summary=summary_line or f"{label}: {outcome}", + stdout=stdout, + stderr=stderr, + ) + + +def collect_provider_diagnostics() -> list[ProviderDiagnostic]: + command = [sys.executable, "-m", "minicode.headless", "Reply with exactly OK."] + try: + completed = subprocess.run( + command, + cwd=Path(__file__).resolve().parents[1], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=180, + check=False, + ) + return [ + _classify_provider_diagnostic( + label="headless-smoke", + command=" ".join(command), + exit_code=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + ] + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout or "" + stderr = exc.stderr or "" + return [ + ProviderDiagnostic( + label="headless-smoke", + outcome="timeout", + command=" ".join(command), + exit_code=124, + summary="Headless provider smoke timed out.", + stdout=stdout if isinstance(stdout, str) else "", + stderr=stderr if isinstance(stderr, str) else "", + ) + ] + + +def main() -> None: + rows = evaluate_runtime_profiles( + scenarios=build_demo_scenarios(), + conditions=build_demo_conditions(), + ) + provider_diagnostics = collect_provider_diagnostics() + payload = runtime_profile_eval_as_dict(rows, provider_diagnostics) + output_path = Path("benchmarks") / "runtime_profile_eval_results.json" + markdown_path = Path("benchmarks") / "runtime_profile_eval_results.md" + output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + markdown_path.write_text( + runtime_profile_eval_as_markdown(rows, provider_diagnostics), + encoding="utf-8", + ) + print(output_path) + print(markdown_path) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/runtime_profile_eval_results.json b/benchmarks/runtime_profile_eval_results.json new file mode 100644 index 0000000..a8a9c1c --- /dev/null +++ b/benchmarks/runtime_profile_eval_results.json @@ -0,0 +1,161 @@ +{ + "rows": [ + { + "scenario": "depth-budget-floor", + "condition": "single", + "runtime_profile": "single", + "wall_time_ms": 4.343099979450926, + "model_calls": 1, + "tool_starts": 0, + "tool_results": 0, + "progress_events": 2, + "runtime_events": 2, + "runtime_event_counts": { + "phase": 1, + "stop": 1 + }, + "runtime_trace": [ + "phase:explore@1", + "stop:max_steps@1" + ], + "assistant_messages": 1, + "stop_reason": "max_steps", + "widened": false, + "verification_guard_triggered": false, + "completed": false, + "final_message": "Reached the maximum tool step limit for this turn." + }, + { + "scenario": "depth-budget-floor", + "condition": "single-deep", + "runtime_profile": "single-deep", + "wall_time_ms": 1.3965999823994935, + "model_calls": 2, + "tool_starts": 0, + "tool_results": 0, + "progress_events": 2, + "runtime_events": 2, + "runtime_event_counts": { + "phase": 1, + "stop": 1 + }, + "runtime_trace": [ + "phase:explore@1", + "stop:done@2" + ], + "assistant_messages": 1, + "stop_reason": "done", + "widened": false, + "verification_guard_triggered": false, + "completed": true, + "final_message": "done" + }, + { + "scenario": "widening-escalation", + "condition": "single", + "runtime_profile": "single", + "wall_time_ms": 1.2136000150348991, + "model_calls": 1, + "tool_starts": 0, + "tool_results": 0, + "progress_events": 2, + "runtime_events": 2, + "runtime_event_counts": { + "phase": 1, + "stop": 1 + }, + "runtime_trace": [ + "phase:explore@1", + "stop:max_steps@1" + ], + "assistant_messages": 1, + "stop_reason": "max_steps", + "widened": false, + "verification_guard_triggered": false, + "completed": false, + "final_message": "Reached the maximum tool step limit for this turn." + }, + { + "scenario": "widening-escalation", + "condition": "single-deep", + "runtime_profile": "single-deep", + "wall_time_ms": 1.8911999941337854, + "model_calls": 10, + "tool_starts": 0, + "tool_results": 0, + "progress_events": 11, + "runtime_events": 7, + "runtime_event_counts": { + "phase": 5, + "widening": 1, + "stop": 1 + }, + "runtime_trace": [ + "phase:explore@1", + "phase:execute@3", + "phase:verify@4", + "phase:verify@9", + "widen:the model stalled repeatedly before producing new evidence@9", + "phase:execute@10", + "stop:done@10" + ], + "assistant_messages": 1, + "stop_reason": "done", + "widened": true, + "verification_guard_triggered": false, + "completed": true, + "final_message": "done with a broader plan" + } + ], + "summary": { + "single": { + "runs": 2, + "completed_runs": 0, + "widened_runs": 0, + "verification_guard_runs": 0, + "total_model_calls": 2, + "total_tool_starts": 0, + "total_tool_results": 0, + "total_runtime_events": 4, + "total_wall_time_ms": 5.556699994485825, + "completion_rate": 0.0, + "widened_rate": 0.0, + "verification_guard_rate": 0.0, + "avg_model_calls": 1.0, + "avg_tool_starts": 0.0, + "avg_tool_results": 0.0, + "avg_runtime_events": 2.0, + "avg_wall_time_ms": 2.7783499972429127 + }, + "single-deep": { + "runs": 2, + "completed_runs": 2, + "widened_runs": 1, + "verification_guard_runs": 0, + "total_model_calls": 12, + "total_tool_starts": 0, + "total_tool_results": 0, + "total_runtime_events": 9, + "total_wall_time_ms": 3.287799976533279, + "completion_rate": 1.0, + "widened_rate": 0.5, + "verification_guard_rate": 0.0, + "avg_model_calls": 6.0, + "avg_tool_starts": 0.0, + "avg_tool_results": 0.0, + "avg_runtime_events": 4.5, + "avg_wall_time_ms": 1.6438999882666394 + } + }, + "provider_diagnostics": [ + { + "label": "headless-smoke", + "outcome": "provider_outage", + "command": "C:\\Users\\question\\anaconda3\\python.exe -m minicode.headless Reply with exactly OK.", + "exit_code": 0, + "summary": "Provider availability failure: deepseek-v4-pro[1m] failed and all viable fallback models were unavailable. Remaining blocker is upstream provider/channel availability, not a local retry loop. Active channel: anthropic-compatible via baseUrl/authToken. Last error (RuntimeError): No available channel for model deepseek-v4-pro[1m] under group cc (distributor) (request id: 202606060346468033683028268d9d66QHAvNlc) Next step: Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken.", + "stdout": "Provider availability failure: deepseek-v4-pro[1m] failed and all viable fallback models were unavailable. Remaining blocker is upstream provider/channel availability, not a local retry loop. Active channel: anthropic-compatible via baseUrl/authToken. Last error (RuntimeError): No available channel for model deepseek-v4-pro[1m] under group cc (distributor) (request id: 202606060346468033683028268d9d66QHAvNlc) Next step: Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken.\n", + "stderr": "2026-06-06 11:46:39,514 [ERROR] minicode.agent_loop: Model API error (RuntimeError): No available channel for model deepseek-v4-pro[1m] under group cc (distributor) (request id: 202606060346468033683028268d9d66QHAvNlc)\n2026-06-06 11:46:39,514 [ERROR] minicode.model_switcher: Model fallback failed: Switch [FAILED]: deepseek-v4-pro[1m] (anthropic) -> (unknown) Errors: No viable fallback models were available\n" + } + ] +} \ No newline at end of file diff --git a/benchmarks/runtime_profile_eval_results.md b/benchmarks/runtime_profile_eval_results.md new file mode 100644 index 0000000..c63f4ff --- /dev/null +++ b/benchmarks/runtime_profile_eval_results.md @@ -0,0 +1,30 @@ +# Runtime Profile Eval + +## Summary + +| condition | runs | completion_rate | widened_rate | verification_guard_rate | avg_model_calls | avg_runtime_events | avg_wall_time_ms | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| single | 2 | 0.00 | 0.00 | 0.00 | 1.00 | 2.00 | 2.78 | +| single-deep | 2 | 1.00 | 0.50 | 0.00 | 6.00 | 4.50 | 1.64 | + +## Scenario Rows + +| scenario | condition | completed | stop_reason | widened | verification_guard | runtime_events | model_calls | wall_time_ms | final_message | +| --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | --- | +| depth-budget-floor | single | no | max_steps | no | no | 2 | 1 | 4.34 | Reached the maximum tool step limit for this turn. | +| depth-budget-floor | single-deep | yes | done | no | no | 2 | 2 | 1.40 | done | +| widening-escalation | single | no | max_steps | no | no | 2 | 1 | 1.21 | Reached the maximum tool step limit for this turn. | +| widening-escalation | single-deep | yes | done | yes | no | 7 | 10 | 1.89 | done with a broader plan | + +## Runtime Timelines + +- `depth-budget-floor` / `single`: phase:explore@1 -> stop:max_steps@1 +- `depth-budget-floor` / `single-deep`: phase:explore@1 -> stop:done@2 +- `widening-escalation` / `single`: phase:explore@1 -> stop:max_steps@1 +- `widening-escalation` / `single-deep`: phase:explore@1 -> phase:execute@3 -> phase:verify@4 -> phase:verify@9 -> widen:the model stalled repeatedly before producing new evidence@9 -> phase:execute@10 -> stop:done@10 + +## Provider Diagnostics + +| label | outcome | exit_code | summary | +| --- | --- | ---: | --- | +| headless-smoke | provider_outage | 0 | Provider availability failure: deepseek-v4-pro[1m] failed and all viable fallback models were unavailable. Remaining blo... | \ No newline at end of file diff --git a/docs/superpowers/plans/2026-06-05-minicode-lite-productization-build.md b/docs/superpowers/plans/2026-06-05-minicode-lite-productization-build.md new file mode 100644 index 0000000..bf324fb --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-minicode-lite-productization-build.md @@ -0,0 +1,72 @@ +--- +archived-with: 2026-06-05-minicode-lite-productization +status: final +--- +## Goal + +Turn `minicode` into a lightweight Claude Code-style product surface by +completing the planned P1-P3 capability layers on top of the existing runtime +kernel. + +## Build Mode + +- Recommended isolation: `branch` +- Recommended build mode: `subagent-driven-development` + +## Workstreams + +### 1. Instruction And Policy Layers + +- Define canonical precedence across global, user, project, and managed policy + sources. +- Add structured instruction layer metadata to prompt/runtime/session outputs. +- Expose inspection commands and live TUI/session visibility. + +### 2. Hook Workflow Productization + +- Add hook registry inspection and recent execution health. +- Persist async hook completion/failure summaries into transcript/session state. +- Productize at least one validation workflow and one delegated completion + workflow. + +### 3. Delegated Background Runtime + +- Introduce a typed delegated runtime record for background/subagent-style work. +- Surface live status and replayable summaries. +- Add bounded retry, recovery, and failure reporting. + +### 4. Extension Packaging + +- Add a local manifest format for extension bundles. +- Implement list, enable, disable, and inspect flows. +- Document repo-local sharing and install workflow. + +### 5. Product Readiness Evaluation + +- Expand runtime evaluation outputs with provider fallback and outage truth. +- Generate a release-readiness artifact that summarizes product health. +- Automate a minimal release gate around live product workflows. + +## Verification + +Required verification before closing the change: + +- targeted tests for each new capability layer, +- full repo `pytest -q`, +- product-style smoke checks for: + - `/session`, `/sessions`, `/session-replay`, + - checkpoint/rewind preview and replay, + - hook workflow visibility, + - delegated runtime visibility, + - provider fallback/outage diagnostics, +- saved artifact-backed release-readiness report. + +## Exit Criteria + +The change is done when: + +- instruction sources are inspectable, +- hooks feel like first-class product workflows, +- delegated background work is bounded and replayable, +- extension packaging is usable locally, +- release readiness reflects real runtime truth rather than tests alone. diff --git a/docs/superpowers/reports/2026-06-05-minicode-lite-productization-verify.md b/docs/superpowers/reports/2026-06-05-minicode-lite-productization-verify.md new file mode 100644 index 0000000..05ff09f --- /dev/null +++ b/docs/superpowers/reports/2026-06-05-minicode-lite-productization-verify.md @@ -0,0 +1,106 @@ +# MiniCode Lite Productization Verify + +Date: 2026-06-05 + +## Scope + +This verify report closes the `minicode-lite-productization` change and records +the P1-P3 surfaces that were added to move `minicode` toward a lightweight +Claude Code product shape. + +## What shipped + +### Instruction and policy layers + +- structured instruction-layer metadata now travels through prompt assembly, + live runtime state, saved sessions, inspect, and replay views +- `/instructions` exposes the effective instruction surface in local command + flows + +### Hook workflow productization + +- hook health is visible through live summaries, saved-session surfaces, and + `/hooks` +- recent hook execution status is bounded and inspectable instead of leaking + into ordinary assistant output + +### Delegated background runtime + +- delegated-task records and retry/recovery status now persist in session data +- `/delegation`, live TUI summaries, and session replay expose delegated + runtime state as a first-class product surface + +### Extension packaging + +- local extension manifests are now discoverable across project and global + roots +- `/extensions`, `/extension-inspect`, `/extension-enable`, and + `/extension-disable` are live +- the local sharing/install workflow is documented in + `docs/superpowers/reports/2026-06-05-minicode-local-extension-workflow.md` + +### Product readiness evaluation + +- runtime profile artifacts now include provider outage diagnostics +- `benchmarks/release_readiness.py` produces a saved release gate artifact with + full-repo verification, product smokes, and provider truth +- release-readiness outputs now distinguish local product health from upstream + provider availability + +## Verification + +### Targeted checks + +- `python -m py_compile minicode/product_surfaces.py minicode/cli_commands.py minicode/runtime_profile_eval.py minicode/release_readiness.py benchmarks/runtime_profile_eval.py benchmarks/release_readiness.py tests/test_cli_commands.py tests/test_runtime_profile_eval.py tests/test_release_readiness.py tests/test_session.py tests/test_tty_app.py tests/test_main.py` +- `pytest -q tests/test_cli_commands.py tests/test_runtime_profile_eval.py tests/test_release_readiness.py tests/test_session.py tests/test_tty_app.py tests/test_main.py` + - result: `94 passed` + +### Product artifacts + +- `python benchmarks/runtime_profile_eval.py` + - wrote: + - `benchmarks/runtime_profile_eval_results.json` + - `benchmarks/runtime_profile_eval_results.md` +- `python benchmarks/release_readiness.py` + - wrote: + - `benchmarks/release_readiness_results.json` + - `benchmarks/release_readiness_results.md` + +### Full verification + +- `pytest -q` + - result: `1016 passed, 2 skipped, 3 warnings` + - the remaining warnings are the existing unregistered + `pytest.mark.benchmark` markers in `tests/test_memory_benchmark.py` + +## Release-readiness result + +Current release gate status is `warning`, not `blocked`. + +- core compile/test/runtime gates passed +- product smoke flows passed: + - `--list-sessions` + - `--inspect-session latest` + - `--replay-session latest` + - `--preview-rewind latest` +- provider diagnostics still show `provider_outage` for the active upstream + model path + +This is the intended outcome boundary: the local product surface is green, and +the remaining warning is upstream provider availability rather than a local +runtime/product failure. + +## Positioning update + +With this change, `minicode` now has a stronger claim to the "lightweight +Claude Code" position: + +- session-first +- recovery-first +- runtime-observable +- replayable and inspectable +- locally extensible + +It still stops short of full Claude Code parity on background orchestration, +enterprise policy, and managed provider reliability, but the P1-P3 lightweight +product surface is now implemented and verified. diff --git a/docs/superpowers/specs/2026-06-05-minicode-lite-productization-design.md b/docs/superpowers/specs/2026-06-05-minicode-lite-productization-design.md new file mode 100644 index 0000000..a21f5f4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-minicode-lite-productization-design.md @@ -0,0 +1,134 @@ +--- +archived-with: 2026-06-05-minicode-lite-productization +status: final +status: final +--- +## Overview + +`minicode` already has a strong local runtime kernel: typed phases, verification +guards, widening, session replay, runtime summaries, and checkpoint/rewind. The +remaining gap against a lightweight Claude Code experience is no longer the core +loop. It is the missing product surface around that loop. + +This design turns the next stage into five connected capability layers: + +1. instruction and policy layers, +2. first-class hook workflows, +3. delegated background runtime, +4. lightweight extension packaging, +5. release-facing readiness evaluation. + +The guiding constraint is to stay lightweight and local-first. We should make +the current runtime more inspectable, more recoverable, and more shareable +without adding a heavy cloud control plane. + +## Product Positioning + +The target is not "full Claude Code parity." The target is a lightweight local +coding agent that preserves the most valuable operator experience: + +- clear instruction precedence, +- transparent async behavior, +- bounded delegation, +- recoverable file edits, +- replayable sessions, +- artifact-backed runtime health. + +## Scope + +### P1 + +- Explicit instruction-policy layers with precedence and origin metadata. +- First-class hook workflows with inspectable health and operator-facing status. +- Runtime governance UX across CLI, TUI, and saved sessions. + +### P2 + +- Delegated/background runtime as a typed, replayable product surface. +- Lightweight local extension manifests and extension lifecycle commands. +- Shared inspection outputs for delegated work, hooks, and runtime metadata. + +### P3 + +- Release-readiness artifacts that combine tests, runtime profiles, provider + fallback truth, and product smoke checks. +- A minimal release gate that checks real product workflows, not just unit + tests. + +## Architectural Direction + +### 1. Instruction Governance Uses Structured Layers + +Instruction loading should no longer be implicit prompt text only. Each runtime +turn should know which global, user, project, and machine-managed instruction +sources were active, in what precedence order, and from which file paths. + +That same structure should power: + +- prompt assembly debugging, +- live `/session` inspection, +- saved session replay, +- release-readiness evidence. + +### 2. Hooks, Delegation, And Runtime Events Share A Common Story + +Hooks, background tasks, and delegated subagent-style runs should all publish a +bounded structured summary. The summary must answer: + +- what started, +- why it started, +- what produced output, +- whether it failed or recovered, +- where the replayable artifact lives. + +This avoids opaque async behavior and keeps the runtime explainable. + +### 3. Extension Packaging Must Stay Local-First + +The first packaging layer should be file-based, inspectable, and shareable +inside repos. We do not need a remote marketplace. We do need a durable manifest +format and operator commands for listing, enabling, disabling, and inspecting +extensions. + +### 4. Release Readiness Must Reflect Real Runtime Health + +Recent experience showed that full tests can pass while real provider routes are +degraded. P3 therefore needs a release artifact that combines: + +- full repo test pass state, +- runtime profile benchmarks, +- provider fallback diagnostics, +- checkpoint/rewind smoke checks, +- session inspect/replay smoke checks, +- delegated runtime smoke checks. + +## Sequencing + +1. Land instruction-policy layers first so later surfaces share a stable + governance model. +2. Productize hooks next because they already sit inside the runtime lifecycle. +3. Build delegated runtime and extension packaging on top of the same summary + and session surfaces. +4. Finish with release-readiness automation once the new structured outputs + exist. + +## Risks + +- Scope sprawl across P1-P3. + Mitigation: keep one shared data model and reuse current session/runtime + surfaces. +- New async features become opaque. + Mitigation: require replayable summaries before declaring features complete. +- Packaging grows into a platform project. + Mitigation: keep manifests local-first and defer remote registry work. +- Product health reporting drifts away from real usage. + Mitigation: make real provider fallback and product workflow smoke checks part + of the release artifact. + +## Evidence Anchor + +Current repo baseline before this change planning: + +- `pytest -q` -> `1005 passed, 2 skipped, 3 warnings` +- The remaining warnings are existing `pytest.mark.benchmark` registration + warnings, not functional failures. diff --git a/minicode/agent_loop.py b/minicode/agent_loop.py index 26cc5c7..2a7fd09 100644 --- a/minicode/agent_loop.py +++ b/minicode/agent_loop.py @@ -2,15 +2,24 @@ import concurrent.futures import inspect +import re import time from typing import Any, Callable +from minicode.config import describe_fallback_guidance, describe_provider_channel from minicode.context_manager import ContextManager, estimate_message_tokens from minicode.logging_config import get_logger +from minicode.model_registry import detect_provider from minicode.permissions import PermissionManager from minicode.state import Store, AppState, increment_tool_calls, set_busy, set_idle from minicode.tooling import ToolContext, ToolRegistry, ToolResult -from minicode.types import AgentStep, ChatMessage, ModelAdapter +from minicode.types import ( + AgentStep, + ChatMessage, + ModelAdapter, + RuntimeEvent, + RuntimeEventCategory, +) # Hooks integration from minicode.hooks import HookEvent, fire_hook_sync @@ -18,15 +27,17 @@ # Intelligence integration from minicode.agent_metrics import AgentMetricsCollector from minicode.agent_intelligence import ErrorClassifier, NudgeGenerator, ToolScheduler -from minicode.working_memory import protect_context +from minicode.working_memory import get_working_memory, protect_context # Work chain integration from minicode.intent_parser import parse_intent from minicode.task_object import build_task, TaskObject, TaskState +from minicode.task_graph import TaskGraph, TaskState as GraphTaskState from minicode.pipeline_engine import get_pipeline_engine from minicode.capability_registry import get_registry, CapabilityDomain from minicode.layered_context import ContextBuilder, LayeredContext from minicode.decision_audit import get_auditor, DecisionOutcome +from minicode.runtime_profiles import resolve_runtime_profile # 工程控制论集成 from minicode.cybernetic_orchestrator import CyberneticOrchestrator @@ -60,6 +71,19 @@ from minicode.context_cybernetics import ContextCyberneticsOrchestrator from minicode.cost_control import CostControlLoop from minicode.memory import MemoryManager +from minicode.turn_kernel import ( + TurnPreludeState, + TurnRecurrentState, + TurnVerificationState, + build_stable_task_pack, + build_turn_coda_summary, + build_widening_transition_nudge, + decide_tool_turn, + decide_assistant_turn, + derive_turn_step_policy, + finalize_work_chain_task, + render_turn_policy_message, +) logger = get_logger("agent_loop") @@ -99,6 +123,140 @@ ) +STABLE_TASK_STATE_MARKER = "[Stable task state]" +_MODEL_FALLBACK_ERROR_HINTS = ( + "no available channel", + "temporarily unavailable", + "service unavailable", + "please try again later", + "capacity exceeded", + "overloaded", + "high demand", + "503", + "502", + "500", + "connection refused", + "connection reset", + "timed out", + "timeout", +) +_MODEL_FALLBACK_BLOCK_HINTS = ( + "unauthorized", + "forbidden", + "invalid api key", + "authentication", + "bad request", + "invalid_request", + "validation", + "tool schema", + "context length", +) + + +def _upsert_stable_task_state_message( + messages: list[ChatMessage], + stable_text: str, +) -> list[ChatMessage]: + filtered = [ + message + for message in messages + if not ( + message.get("role") == "system" + and str(message.get("content", "")).startswith(STABLE_TASK_STATE_MARKER) + ) + ] + filtered.append( + { + "role": "system", + "content": f"{STABLE_TASK_STATE_MARKER}\n{stable_text}", + } + ) + return filtered + + +def _should_attempt_model_fallback(error_message: str) -> bool: + normalized = error_message.lower() + if any(marker in normalized for marker in _MODEL_FALLBACK_BLOCK_HINTS): + return False + return any(marker in normalized for marker in _MODEL_FALLBACK_ERROR_HINTS) + + +def _looks_like_provider_availability_error(error_message: str) -> bool: + normalized = error_message.lower() + return any( + marker in normalized + for marker in ( + "no available channel", + "temporarily unavailable", + "service unavailable", + "please try again later", + "capacity exceeded", + "overloaded", + "high demand", + "503", + "502", + "500", + ) + ) + + +def _summarize_model_api_failure( + *, + error_type: str, + error: Exception, + active_model_id: str = "", + fallback_errors: list[str] | None = None, + runtime: dict[str, Any] | None = None, +) -> str: + fallback_errors = fallback_errors or [] + if fallback_errors: + combined = " ".join(fallback_errors) + if ( + "no viable fallback models were available" in combined.lower() + and any(_looks_like_provider_availability_error(item) for item in fallback_errors + [str(error)]) + ): + model_label = active_model_id or "the active model" + runtime = runtime or {} + provider = detect_provider(model_label, runtime).value if model_label else "unknown" + channel = describe_provider_channel(runtime, provider) + guidance = describe_fallback_guidance( + runtime, + provider_name=provider, + current_model=model_label or str(runtime.get("model", "")).strip(), + ) + guidance_suffix = f" Next step: {guidance[0]}" if guidance else "" + return ( + f"Provider availability failure: {model_label} failed and all viable fallback models were unavailable. " + f"Remaining blocker is upstream provider/channel availability, not a local retry loop. " + f"Active channel: {channel}. Last error ({error_type}): {error}{guidance_suffix}" + ) + return f"Model API error ({error_type}): {error}" + + +def _extract_model_id_from_provider_error(error: Exception) -> str: + message = str(error) + match = re.search(r"model\s+([^\s]+)\s+under\s+group", message, flags=re.IGNORECASE) + if match: + return match.group(1).strip() + return "" + + +def _infer_active_model_id( + model: ModelAdapter, + runtime: dict[str, Any] | None, + error: Exception | None = None, +) -> str: + explicit = str(getattr(model, "model_id", "") or "").strip() + if explicit: + return explicit + runtime_model = str((runtime or {}).get("model", "") or "").strip() + if runtime_model: + return runtime_model + if error is not None: + return _extract_model_id_from_provider_error(error) + return "" + + def _is_empty_assistant_response(content: str) -> bool: return len(content.strip()) == 0 @@ -210,6 +368,7 @@ def _execute_single_tool( tools: ToolRegistry, cwd: str, permissions: Any | None, + session: Any | None, runtime: dict | None, store: Any | None, step: int, @@ -252,7 +411,7 @@ def _execute_single_tool( future = pool.submit( tools.execute, tool_name, tool_input, - ToolContext(cwd=cwd, permissions=permissions, _runtime=runtime), + ToolContext(cwd=cwd, permissions=permissions, session=session, _runtime=runtime), ) result = future.result(timeout=TOOL_TIMEOUT) except concurrent.futures.TimeoutError: @@ -263,7 +422,7 @@ def _execute_single_tool( except Exception: result = tools.execute( tool_name, tool_input, - ToolContext(cwd=cwd, permissions=permissions, _runtime=runtime), + ToolContext(cwd=cwd, permissions=permissions, session=session, _runtime=runtime), ) # Fallback: direct execution # Post-tool state updates (only for serial execution) @@ -507,12 +666,14 @@ def run_agent_turn( messages: list[ChatMessage], cwd: str, permissions: PermissionManager | None = None, + session: Any | None = None, store: Store[AppState] | None = None, max_steps: int = 50, on_tool_start: Callable[[str, dict], None] | None = None, on_tool_result: Callable[[str, str, bool], None] | None = None, on_assistant_message: Callable[[str], None] | None = None, on_progress_message: Callable[[str], None] | None = None, + on_runtime_event: Callable[[RuntimeEvent], None] | None = None, on_assistant_stream_chunk: Callable[[str], None] | None = None, on_thinking_chunk: Callable[[str], None] | None = None, context_manager: ContextManager | None = None, @@ -522,21 +683,53 @@ def run_agent_turn( project_context: str = "", enable_work_chain: bool = True, ) -> list[ChatMessage]: + # Prelude: prepare per-turn state before we enter the recurrent think/act loop. current_messages = list(messages) - saw_tool_result = False - empty_response_retry_count = 0 - recoverable_thinking_retry_count = 0 - tool_error_count = 0 - step = 0 + runtime_profile = resolve_runtime_profile(runtime, fallback_max_steps=max_steps) + turn_state = TurnRecurrentState( + max_steps=runtime_profile.max_steps, + profile_name=runtime_profile.name, + widen_after_step=runtime_profile.widen_after_step, + empty_response_retry_limit=runtime_profile.empty_response_retry_limit, + recoverable_thinking_retry_limit=runtime_profile.recoverable_thinking_retry_limit, + verification_state=TurnVerificationState( + strict=runtime_profile.strict_step_verification, + requires_explicit_final=runtime_profile.strict_step_verification, + ), + ) + max_steps = runtime_profile.max_steps + + def emit_runtime_event( + *, + category: RuntimeEventCategory, + message: str, + emit_progress: bool = True, + stop_reason: str = "", + widening_reason: str = "", + evidence_summary: str = "", + ) -> None: + policy = turn_state.step_policy + event = RuntimeEvent( + category=category, + message=message, + step=turn_state.step or None, + profile=runtime_profile.name, + phase=policy.phase if policy is not None else "", + verification_focus=( + policy.verification_focus if policy is not None else "" + ), + stop_reason=stop_reason, + widening_reason=widening_reason, + evidence_summary=evidence_summary, + ) + if on_runtime_event: + on_runtime_event(event) + if emit_progress and on_progress_message: + on_progress_message(message) tool_scheduler = ToolScheduler(metrics_collector=metrics_collector) - # Initialize work chain if enabled - task: TaskObject | None = None - task_metadata: dict = {} - layered_context: LayeredContext | None = None - context_builder: ContextBuilder | None = None - auditor = get_auditor() if enable_work_chain else None + prelude = TurnPreludeState(auditor=get_auditor() if enable_work_chain else None) # 工程控制论控制器初始化(通过 Orchestrator 统一管理) orch: CyberneticOrchestrator | None = None @@ -559,9 +752,19 @@ def run_agent_turn( memory_injector: Any = None if enable_work_chain: - task, task_metadata = _build_work_chain_task(current_messages) - layered_context, context_builder = _build_layered_context( - current_messages, system_prompt, project_context, task, + prelude.task, prelude.task_metadata = _build_work_chain_task(current_messages) + if prelude.task: + prelude.task_graph = TaskGraph(name=f"turn-{prelude.task.id}") + graph_task = prelude.task_graph.add_task( + name=prelude.task.title or prelude.task.id, + description=prelude.task.goal or prelude.task.description, + ) + prelude.task_graph_id = graph_task.id + slot = prelude.task_graph.assign_slot(graph_task.id, slot_name="turn") + prelude.task_slot_key = f"{slot.slot_name}:{slot.task_id}" + prelude.task_graph.start_task(prelude.task_slot_key) + prelude.layered_context, prelude.context_builder = _build_layered_context( + current_messages, system_prompt, project_context, prelude.task, ) get_pipeline_engine() _register_tool_capabilities(tools) @@ -583,10 +786,10 @@ def run_agent_turn( reflection_engine = orch.reflection model_switcher = orch.model_switcher logger.info("CyberneticOrchestrator: %d controllers initialized", 15) - if smart_router and task: + if smart_router and prelude.task: try: current_model_id = model.model_id if hasattr(model, 'model_id') else "" - task_text = task.raw_input if hasattr(task, 'raw_input') else str(current_messages[-1].get('content', '')) + task_text = prelude.task.raw_input if hasattr(prelude.task, 'raw_input') else str(current_messages[-1].get('content', '')) routing, switch_result = smart_router.route_and_switch( task_text, current_model=current_model_id, @@ -607,17 +810,24 @@ def run_agent_turn( pass # 初始化前馈控制器(预判式优化) - if task: + if prelude.task: feedforward_controller = FeedforwardController() - preemptive_config = feedforward_controller.preconfigure(task.parsed_intent, task.raw_input) - risk_assessment = feedforward_controller.assess_risks(task.parsed_intent, preemptive_config) + preemptive_config = feedforward_controller.preconfigure(prelude.task.parsed_intent, prelude.task.raw_input) + risk_assessment = feedforward_controller.assess_risks(prelude.task.parsed_intent, preemptive_config) logger.info( "Feedforward control: config=%s risk=%s", preemptive_config.recommended_model, risk_assessment.risk_level, ) # Apply feedforward preemptive config to execution parameters if preemptive_config.confidence > 0.6: - max_steps = min(max_steps, preemptive_config.max_turn_steps) + if turn_state.max_steps is None: + turn_state.max_steps = preemptive_config.max_turn_steps + else: + turn_state.max_steps = min( + turn_state.max_steps, + preemptive_config.max_turn_steps, + ) + max_steps = turn_state.max_steps logger.info( "Feedforward: max_steps=%d model=%s timeout=%.1fs", preemptive_config.max_turn_steps, @@ -633,10 +843,10 @@ def run_agent_turn( ) # 模型选择控制器:根据任务特征推荐模型 - if model_selection_ctrl and task: + if model_selection_ctrl and prelude.task: try: model_signal = ModelSelectionSignal( - task_complexity=getattr(task, 'complexity', 'moderate') if hasattr(task, 'complexity') else "moderate", + task_complexity=getattr(prelude.task, 'complexity', 'moderate') if hasattr(prelude.task, 'complexity') else "moderate", budget_pressure=0.3, latency_pressure=0.3, recent_failures=0, @@ -709,15 +919,15 @@ def run_agent_turn( except Exception: pass # 执行实际记忆注入:将相关记忆注入到系统 prompt 中 - if orch and task: + if orch and prelude.task: try: - task_desc = task.raw_input if hasattr(task, 'raw_input') else "" + task_desc = prelude.task.raw_input if hasattr(prelude.task, 'raw_input') else "" current_messages = orch.inject_memories(task_desc, current_messages) except Exception: pass - elif memory_injector and task: + elif memory_injector and prelude.task: try: - task_desc = task.raw_input if hasattr(task, 'raw_input') else "" + task_desc = prelude.task.raw_input if hasattr(prelude.task, 'raw_input') else "" injected = memory_injector.inject_for_task(task_desc) if injected: logger.info( @@ -753,8 +963,8 @@ def run_agent_turn( safety_margin_turns=3, enabled=True, ) - if task and hasattr(task, 'parsed_intent') and task.parsed_intent: - context_cybernetics.set_intent(str(task.parsed_intent.intent_type)) + if prelude.task and hasattr(prelude.task, 'parsed_intent') and prelude.task.parsed_intent: + context_cybernetics.set_intent(str(prelude.task.parsed_intent.intent_type)) logger.info("ContextCybernetics initialized: PID control loop + predictive guard") if orch: orch.context_compactor = context_compactor @@ -798,7 +1008,7 @@ def run_agent_turn( adj = cost_control.run( cost_usd=est_cost, total_tokens=stats.total_tokens, - total_calls=max(step, 1), + total_calls=max(turn_state.step, 1), ) if context_compactor and hasattr(context_compactor, '_tool_budget') and context_compactor._tool_budget: cost_control.apply_to_budget_manager(context_compactor._tool_budget) @@ -810,9 +1020,9 @@ def run_agent_turn( cyber_messages, cyber_result, cyber_action = context_cybernetics.run_cycle( current_messages, - error_rate=float(tool_error_count) / max(step, 1) if step > 0 else 0.0, - avg_latency=step * 2.0, - turn_id=step, + error_rate=float(turn_state.tool_error_count) / max(turn_state.step, 1) if turn_state.step > 0 else 0.0, + avg_latency=turn_state.step * 2.0, + turn_id=turn_state.step, ) if cyber_result and cyber_result.effective: current_messages = cyber_messages @@ -843,8 +1053,56 @@ def run_agent_turn( on_assistant_message(context_manager.get_context_summary()) try: - while max_steps is None or step < max_steps: - step += 1 + # Recurrent kernel: repeated think/act/observe iterations over one turn. + while turn_state.has_remaining_steps(): + step = turn_state.begin_step() + previous_policy = turn_state.step_policy + current_policy = derive_turn_step_policy(turn_state) + policy_message = render_turn_policy_message( + previous_policy=previous_policy, + current_policy=current_policy, + ) + if policy_message: + turn_state.set_progress_summary(policy_message) + emit_runtime_event(category="phase", message=policy_message) + logger.info("Turn policy update: %s", policy_message) + if ( + current_policy.should_compact_aggressively + and context_manager + and context_manager.should_auto_compact() + ): + current_messages = context_manager.compact_messages() + emit_runtime_event( + category="compaction", + message="Compacted context for the current runtime phase.", + ) + protected_context = get_working_memory().get_protected_content() + turn_state.stable_task_pack = build_stable_task_pack( + task=prelude.task, + task_metadata=prelude.task_metadata, + protected_context=protected_context, + task_graph=prelude.task_graph, + task_slot_key=prelude.task_slot_key, + latest_tool_result_summary=turn_state.latest_tool_result_summary, + progress_state=turn_state.progress_state, + verification_state=turn_state.verification_state, + budget_signals=turn_state.budget_signals, + ) + if turn_state.stable_task_pack: + stable_text = turn_state.stable_task_pack.to_protected_text() + current_messages = _upsert_stable_task_state_message( + current_messages, + stable_text, + ) + if runtime_profile.name == "single-deep": + protect_context( + content=stable_text, + entry_type="active_task", + ttl_seconds=runtime_profile.working_memory_ttl_seconds, + importance=runtime_profile.working_memory_importance, + ) + if context_manager: + context_manager.messages = current_messages # Hook: agent turn started fire_hook_sync(HookEvent.AGENT_START, step=step, cwd=cwd) @@ -854,8 +1112,8 @@ def run_agent_turn( orch.step_start( context_manager=context_manager, step=step, - tool_error_count=tool_error_count, - saw_tool_result=saw_tool_result, + tool_error_count=turn_state.tool_error_count, + saw_tool_result=turn_state.saw_tool_result, ) elif enable_work_chain: # 状态观测:通过可测量输出估计系统内部状态 @@ -863,9 +1121,9 @@ def run_agent_turn( measurement = MeasurementVector( timestamp=time.time(), response_time=step * 2.0, # 估算响应时间 - success_rate=1.0 - (tool_error_count / max(step, 1)), + success_rate=1.0 - (turn_state.tool_error_count / max(step, 1)), context_length=context_manager.get_stats().total_tokens if context_manager else 0, - error_count=tool_error_count, + error_count=turn_state.tool_error_count, tool_calls=0, ) observed_state = state_observer.update(measurement) @@ -894,7 +1152,7 @@ def run_agent_turn( if context_manager: stats = context_manager.get_stats() predictive_controller.update("context_usage", stats.usage_percentage / 100.0) - predictive_controller.update("error_rate", tool_error_count / max(step, 1)) + predictive_controller.update("error_rate", turn_state.tool_error_count / max(step, 1)) if step > 2: actions = predictive_controller.generate_predictive_actions() @@ -931,7 +1189,7 @@ def run_agent_turn( if self_healing_engine: healing_actions = self_healing_engine.detect_and_heal({ "context_usage": stats.usage_percentage / 100.0 if context_manager else 0.0, - "error_rate": tool_error_count / max(step, 1), + "error_rate": turn_state.tool_error_count / max(step, 1), }) if healing_actions: logger.info("Self-healing: %s", healing_actions[0].strategy) @@ -953,6 +1211,13 @@ def run_agent_turn( except ConnectionError as error: fallback = f"Network error (connection failed or dropped): {error}" logger.error("Model API connection error: %s", error) + turn_state.set_stop_reason("blocked") + emit_runtime_event( + category="stop", + message=fallback, + emit_progress=False, + stop_reason="blocked", + ) if on_assistant_message: on_assistant_message(fallback) current_messages.append({"role": "assistant", "content": fallback}) @@ -962,6 +1227,13 @@ def run_agent_turn( except TimeoutError as error: fallback = f"Model API timeout: {error}" logger.error("Model API timeout: %s", error) + turn_state.set_stop_reason("blocked") + emit_runtime_event( + category="stop", + message=fallback, + emit_progress=False, + stop_reason="blocked", + ) if on_assistant_message: on_assistant_message(fallback) current_messages.append({"role": "assistant", "content": fallback}) @@ -971,7 +1243,13 @@ def run_agent_turn( except Exception as error: # Catch-all for unexpected errors (rate limit, auth, server 5xx, etc.) error_type = type(error).__name__ - fallback = f"Model API error ({error_type}): {error}" + active_model_id = _infer_active_model_id(model, runtime, error) + fallback = _summarize_model_api_failure( + error_type=error_type, + error=error, + active_model_id=active_model_id, + runtime=runtime, + ) logger.error("Model API error (%s): %s", error_type, error) # Reactive Compact: 控制论恢复路径 @@ -1001,24 +1279,52 @@ def run_agent_turn( continue # ModelSwitcher: 尝试切换到备用模型并重试 - if model_switcher and "rate" not in error_str: + if model_switcher and "rate" not in error_str and _should_attempt_model_fallback(error_str): try: + if hasattr(model_switcher, "sync_current_model"): + model_switcher.sync_current_model(active_model_id, adapter=model) + if hasattr(model_switcher, "record_runtime_failure"): + model_switcher.record_runtime_failure(active_model_id) + if runtime is not None: + runtime["recentFailures"] = int(runtime.get("recentFailures", 0) or 0) + 1 switch_result = model_switcher.switch_to( "", # Let switcher pick fallback reason=f"{error_type}: {error_str[:80]}", ) if switch_result.success and switch_result.adapter is not None: model = switch_result.adapter + fallback_message = ( + f"Model fallback: switched from {switch_result.old_model} " + f"to {switch_result.new_model} after {error_type}." + ) logger.info( "ModelSwitcher: switched to %s, retrying with new adapter", switch_result.new_model, ) + emit_runtime_event( + category="recovery", + message=fallback_message, + ) continue + fallback = _summarize_model_api_failure( + error_type=error_type, + error=error, + active_model_id=active_model_id, + fallback_errors=switch_result.errors, + runtime=runtime, + ) except Exception: pass if on_assistant_message: on_assistant_message(fallback) + turn_state.set_stop_reason("blocked") + emit_runtime_event( + category="stop", + message=fallback, + emit_progress=False, + stop_reason="blocked", + ) current_messages.append({"role": "assistant", "content": fallback}) if metrics_collector: metrics_collector.end_turn(total_tokens=0) @@ -1026,102 +1332,181 @@ def run_agent_turn( if next_step.type == "assistant": is_empty = _is_empty_assistant_response(next_step.content) - if not is_empty and _should_treat_assistant_as_progress( - kind=getattr(next_step, 'kind', None), - content=next_step.content, - saw_tool_result=saw_tool_result, - ): - if on_progress_message: - on_progress_message(next_step.content) - current_messages.append({"role": "assistant_progress", "content": next_step.content}) - current_messages.append( - { - "role": "user", - "content": ( - NUDGE_AFTER_TOOL_RESULT - if saw_tool_result and getattr(next_step, 'kind', None) != "progress" - else NUDGE_CONTINUE - ), - } - ) - continue - diagnostics = next_step.diagnostics - - if _is_recoverable_thinking_stop( - is_empty=is_empty, + assistant_decision = decide_assistant_turn( + turn_state=turn_state, + step_content=next_step.content, + step_kind=getattr(next_step, "kind", None), stop_reason=diagnostics.stopReason if diagnostics else None, + block_types=diagnostics.blockTypes if diagnostics else None, ignored_block_types=diagnostics.ignoredBlockTypes if diagnostics else None, - ) and recoverable_thinking_retry_count < 3: - recoverable_thinking_retry_count += 1 - stop_reason = diagnostics.stopReason if diagnostics else None - progress_content = ( - "Model hit max_tokens during thinking; requesting the next step." - if stop_reason == "max_tokens" - else "Model returned pause_turn; requesting the next step." - ) - if on_progress_message: - on_progress_message(progress_content) - current_messages.append({"role": "assistant_progress", "content": progress_content}) - current_messages.append( - { - "role": "user", - "content": ( - RESUME_AFTER_PAUSE - if stop_reason == "pause_turn" - else RESUME_AFTER_MAX_TOKENS - ), - } - ) + is_empty=is_empty, + treat_as_progress=( + not is_empty + and _should_treat_assistant_as_progress( + kind=getattr(next_step, "kind", None), + content=next_step.content, + saw_tool_result=turn_state.saw_tool_result, + ) + ), + is_recoverable_thinking_stop=_is_recoverable_thinking_stop( + is_empty=is_empty, + stop_reason=diagnostics.stopReason if diagnostics else None, + ignored_block_types=diagnostics.ignoredBlockTypes if diagnostics else None, + ), + format_diagnostics=_format_diagnostics, + nudge_continue=NUDGE_CONTINUE, + nudge_after_tool_result=NUDGE_AFTER_TOOL_RESULT, + resume_after_pause=RESUME_AFTER_PAUSE, + resume_after_max_tokens=RESUME_AFTER_MAX_TOKENS, + nudge_after_empty_response=NUDGE_AFTER_EMPTY_RESPONSE, + nudge_after_empty_no_tools=NUDGE_AFTER_EMPTY_NO_TOOLS, + step_policy=turn_state.step_policy, + ) + + if assistant_decision.kind == "progress": + if assistant_decision.assistant_content: + turn_state.set_progress_summary(assistant_decision.assistant_content) + if assistant_decision.runtime_event_category is not None: + emit_runtime_event( + category=assistant_decision.runtime_event_category, + message=assistant_decision.assistant_content, + evidence_summary=( + turn_state.verification_state.evidence_summary + or turn_state.latest_tool_result_summary + ), + ) + elif on_progress_message: + on_progress_message(assistant_decision.assistant_content) + current_messages.append( + { + "role": "assistant_progress", + "content": assistant_decision.assistant_content, + } + ) + if assistant_decision.user_content: + current_messages.append( + { + "role": "user", + "content": assistant_decision.user_content, + } + ) continue - if is_empty and empty_response_retry_count < 2: - empty_response_retry_count += 1 - current_messages.append( - { - "role": "user", - "content": ( - NUDGE_AFTER_EMPTY_RESPONSE - if saw_tool_result - else NUDGE_AFTER_EMPTY_NO_TOOLS - ), - } - ) + if assistant_decision.kind == "retry": + if assistant_decision.user_content: + current_messages.append( + { + "role": "user", + "content": assistant_decision.user_content, + } + ) continue - if is_empty: - diagnostics_suffix = _format_diagnostics( - diagnostics.stopReason if diagnostics else None, - diagnostics.blockTypes if diagnostics else None, - diagnostics.ignoredBlockTypes if diagnostics else None, - ) - if saw_tool_result: - fallback = ( - f"Model returned an empty response after tool execution and the turn was stopped. There were {tool_error_count} tool error(s); retry, adjust the command, or choose a different approach.{diagnostics_suffix}" - if tool_error_count > 0 - else f"Model returned an empty response after tool execution and the turn was stopped. Retry or ask the model to continue the remaining steps.{diagnostics_suffix}" + if assistant_decision.kind == "fallback": + if assistant_decision.stop_reason == "widen_needed": + transitioned = turn_state.activate_widening( + extra_steps=runtime_profile.widening_step_bonus, + ) + if transitioned: + widening_message = ( + assistant_decision.assistant_content + or "Depth stalled; switching to widened mode." + ) + if turn_state.widening_trigger_reason: + widening_message += ( + " Escalation trigger: " + f"{turn_state.widening_trigger_reason}." + ) + turn_state.set_progress_summary( + "runtime widened after the narrow path stalled" + ) + emit_runtime_event( + category="widening", + message=widening_message, + widening_reason=turn_state.widening_trigger_reason, + evidence_summary=turn_state.widening_trigger_evidence, + ) + current_messages.append( + { + "role": "assistant_progress", + "content": widening_message, + } + ) + current_messages.append( + { + "role": "user", + "content": build_widening_transition_nudge( + turn_state.latest_tool_result_summary, + widening_reason=turn_state.widening_trigger_reason, + widening_evidence_summary=turn_state.widening_trigger_evidence, + ), + } + ) + continue + if assistant_decision.stop_reason: + turn_state.set_stop_reason(assistant_decision.stop_reason) + emit_runtime_event( + category="stop", + message=( + assistant_decision.assistant_content + or "Turn stopped without a final answer." + ), + emit_progress=False, + stop_reason=assistant_decision.stop_reason, + evidence_summary=( + turn_state.verification_state.evidence_summary + or turn_state.latest_tool_result_summary + ), + ) + if assistant_decision.assistant_content and on_assistant_message: + on_assistant_message(assistant_decision.assistant_content) + if assistant_decision.assistant_content: + current_messages.append( + { + "role": "assistant", + "content": assistant_decision.assistant_content, + } ) - else: - fallback = f"Model returned an empty response and the turn was stopped.{diagnostics_suffix}" - if on_assistant_message: - on_assistant_message(fallback) - current_messages.append({"role": "assistant", "content": fallback}) return current_messages - if on_assistant_message: - on_assistant_message(next_step.content) - current_messages.append({"role": "assistant", "content": next_step.content}) - # Protect final answer in working memory - protect_context( - content=next_step.content[:500], - entry_type="key_decision", - ttl_seconds=3600, - ) + if assistant_decision.stop_reason: + turn_state.set_stop_reason(assistant_decision.stop_reason) + emit_runtime_event( + category="stop", + message=assistant_decision.assistant_content or "Turn completed.", + emit_progress=False, + stop_reason=assistant_decision.stop_reason, + evidence_summary=( + turn_state.verification_state.evidence_summary + or turn_state.latest_tool_result_summary + ), + ) + if model_switcher and hasattr(model_switcher, "clear_runtime_failures"): + model_switcher.clear_runtime_failures() + if assistant_decision.assistant_content: + turn_state.set_progress_summary("assistant finalized the turn") + if on_assistant_message: + on_assistant_message(assistant_decision.assistant_content) + current_messages.append( + { + "role": "assistant", + "content": assistant_decision.assistant_content, + } + ) + if assistant_decision.protect_final_answer and assistant_decision.assistant_content: + protect_context( + content=assistant_decision.assistant_content[:500], + entry_type="key_decision", + ttl_seconds=runtime_profile.working_memory_ttl_seconds, + importance=runtime_profile.working_memory_importance, + ) return current_messages if next_step.content: role = "assistant_progress" if next_step.contentKind == "progress" else "assistant" if role == "assistant_progress": + turn_state.set_progress_summary(next_step.content) if on_progress_message: on_progress_message(next_step.content) current_messages.append({"role": role, "content": next_step.content}) @@ -1132,11 +1517,23 @@ def run_agent_turn( } ) else: + turn_state.set_progress_summary(next_step.content) if on_assistant_message: on_assistant_message(next_step.content) current_messages.append({"role": role, "content": next_step.content}) if not next_step.calls and next_step.content and next_step.contentKind != "progress": + turn_state.set_stop_reason("done") + emit_runtime_event( + category="stop", + message=next_step.content, + emit_progress=False, + stop_reason="done", + evidence_summary=( + turn_state.verification_state.evidence_summary + or turn_state.latest_tool_result_summary + ), + ) return current_messages # --- Concurrent tool execution --- @@ -1150,7 +1547,7 @@ def run_agent_turn( if metrics_collector: metrics_collector.start_tool(call["toolName"]) result = _execute_single_tool( - call, tools, cwd, permissions, runtime, store, step, + call, tools, cwd, permissions, session, runtime, store, step, on_tool_start, on_tool_result, tool_scheduler, ) if metrics_collector: @@ -1169,9 +1566,9 @@ def run_agent_turn( if concurrent_calls: max_workers = tool_scheduler.get_recommended_max_workers( concurrent_calls, - error_rate=tool_error_count / max(step, 1), + error_rate=turn_state.tool_error_count / max(step, 1), avg_latency=step * 2.0, - recent_failures=tool_error_count, + recent_failures=turn_state.tool_error_count, ) # Apply cybernetic concurrency cap if FeedbackController reduced parallelism force_cap = getattr(tool_scheduler, '_force_max_workers', None) @@ -1192,8 +1589,8 @@ def run_agent_turn( future_to_call = { pool.submit( _execute_single_tool, - call, tools, cwd, permissions, runtime, None, step, - None, None, # No UI callbacks during concurrent phase + call, tools, cwd, permissions, session, runtime, None, step, + None, None, tool_scheduler, # No UI callbacks during concurrent phase ): call for call in concurrent_calls } @@ -1211,7 +1608,7 @@ def run_agent_turn( if metrics_collector: metrics_collector.start_tool(call["toolName"]) result = _execute_single_tool( - call, tools, cwd, permissions, runtime, store, step, + call, tools, cwd, permissions, session, runtime, store, step, on_tool_start, on_tool_result, tool_scheduler, ) if metrics_collector: @@ -1263,12 +1660,19 @@ def run_agent_turn( if on_tool_result: on_tool_result(call["toolName"], result.output, not result.ok) - saw_tool_result = True + tool_summary = f"{call['toolName']}: {result.output[:200]}" + turn_state.record_tool_result(result.ok, summary=tool_summary) + tool_decision = decide_tool_turn( + tool_name=call["toolName"], + result_output=result.output, + await_user=result.awaitUser, + ) + if tool_decision.progress_summary: + turn_state.set_progress_summary(tool_decision.progress_summary) if not result.ok: - tool_error_count += 1 # Use ErrorClassifier for intelligent error handling classified = ErrorClassifier.classify(result.output, tool_name=call["toolName"]) - nudge = NudgeGenerator.generate(classified, retry_count=tool_error_count) + nudge = NudgeGenerator.generate(classified, retry_count=turn_state.tool_error_count) # Append nudge to tool result content for model context result_output = result.output + "\n\n[System note: " + nudge + "]" else: @@ -1321,10 +1725,24 @@ def run_agent_turn( "isError": not result.ok, } ) - if result.awaitUser: - if on_assistant_message: - on_assistant_message(result_output) - current_messages.append({"role": "assistant", "content": result_output}) + if tool_decision.kind == "await_user": + if tool_decision.stop_reason: + turn_state.set_stop_reason(tool_decision.stop_reason) + emit_runtime_event( + category="stop", + message=tool_decision.assistant_content or result_output, + emit_progress=False, + stop_reason=tool_decision.stop_reason, + evidence_summary=turn_state.latest_tool_result_summary, + ) + if tool_decision.assistant_content and on_assistant_message: + on_assistant_message(tool_decision.assistant_content) + current_messages.append( + { + "role": "assistant", + "content": tool_decision.assistant_content or result_output, + } + ) if metrics_collector: metrics_collector.end_turn(total_tokens=0) return current_messages @@ -1340,7 +1758,7 @@ def run_agent_turn( ), "context_pressure_to_errors": ( context_manager.get_stats().usage_percentage / 100.0 if context_manager else 0.0, - tool_error_count / max(step, 1), + turn_state.tool_error_count / max(step, 1), ), }) decoupling_controller.compute_decoupling_matrix() @@ -1350,14 +1768,14 @@ def run_agent_turn( tool_scheduler=tool_scheduler, context_manager=context_manager, step=step, - tool_error_count=tool_error_count, - saw_tool_result=saw_tool_result, - max_steps=max_steps, + tool_error_count=turn_state.tool_error_count, + saw_tool_result=turn_state.saw_tool_result, + max_steps=turn_state.max_steps, ) - max_steps = _apply_control_signal( + turn_state.max_steps = _apply_control_signal( control_signal=step_summary.get("control_signal"), system_state=step_summary.get("system_state"), - max_steps=max_steps, + max_steps=turn_state.max_steps, tool_scheduler=tool_scheduler, context_compactor=context_compactor, model_switcher=model_switcher, @@ -1367,7 +1785,7 @@ def run_agent_turn( # 自愈检测:检测并修复故障 if self_healing_engine: metrics_for_healing = { - "error_rate": tool_error_count / max(step, 1), + "error_rate": turn_state.tool_error_count / max(step, 1), "context_usage": context_manager.get_stats().usage_percentage / 100.0 if context_manager else 0.0, "oscillation_index": feedback_controller._compute_oscillation() if feedback_controller else 0.0, } @@ -1378,14 +1796,14 @@ def run_agent_turn( # 进度控制:检测任务是否卡住或完成 if progress_controller: progress_signal = ProgressSignal( - total_steps=max_steps, - completed_steps=step - tool_error_count, - failed_steps=tool_error_count, + total_steps=turn_state.max_steps, + completed_steps=step - turn_state.tool_error_count, + failed_steps=turn_state.tool_error_count, tool_calls=step, - tool_errors=tool_error_count, - output_changed=saw_tool_result, + tool_errors=turn_state.tool_error_count, + output_changed=turn_state.saw_tool_result, elapsed_seconds=step * 2.0, - max_steps=max_steps, + max_steps=turn_state.max_steps, ) progress_decision = progress_controller.decide(progress_signal) if progress_decision.action in (ProgressAction.STOP, ProgressAction.REQUEST_CONFIRMATION): @@ -1407,12 +1825,33 @@ def run_agent_turn( continue fallback = "Reached the maximum tool step limit for this turn." + turn_state.set_stop_reason("max_steps") + emit_runtime_event( + category="stop", + message=fallback, + emit_progress=False, + stop_reason="max_steps", + evidence_summary=( + turn_state.verification_state.evidence_summary + or turn_state.latest_tool_result_summary + ), + ) if on_assistant_message: on_assistant_message(fallback) current_messages.append({"role": "assistant", "content": fallback}) return current_messages finally: - fire_hook_sync(HookEvent.AGENT_STOP, step=step, tool_errors=tool_error_count) + # Coda: finalize metrics, work-chain bookkeeping, and control summaries. + fire_hook_sync( + HookEvent.AGENT_STOP, + step=turn_state.step, + tool_errors=turn_state.tool_error_count, + ) + step = turn_state.step + tool_error_count = turn_state.tool_error_count + task = prelude.task + task_metadata = prelude.task_metadata + auditor = prelude.auditor if metrics_collector and metrics_collector._current_turn is not None: total_tokens = sum( @@ -1420,50 +1859,101 @@ def run_agent_turn( ) if context_manager else 0 metrics_collector.end_turn(total_tokens=total_tokens) - if enable_work_chain and task: - final_state = TaskState.COMPLETED if tool_error_count == 0 else TaskState.FAILED - task.set_state(final_state) - task.result_summary = f"Turn completed: {step} steps, {tool_error_count} errors" - - if auditor: - outcome = DecisionOutcome.SUCCESS if tool_error_count == 0 else DecisionOutcome.FAILURE - auditor.complete_decision( - outcome, - step * 100.0, - task.result_summary, - task.error_message if tool_error_count > 0 else "", - ) + context_usage = 0.0 + if context_manager: + try: + context_usage = context_manager.get_stats().usage_percentage / 100.0 + except Exception: + context_usage = 0.0 + coda_summary = build_turn_coda_summary( + turn_state=turn_state, + context_usage=context_usage, + ) + + if enable_work_chain and prelude.task: + finalize_work_chain_task( + task=prelude.task, + auditor=prelude.auditor, + coda_summary=coda_summary, + success_outcome=DecisionOutcome.SUCCESS, + failure_outcome=DecisionOutcome.FAILURE, + ) + + if prelude.task_graph and prelude.task_slot_key: + try: + if coda_summary.task_state is TaskState.COMPLETED: + prelude.task_graph.complete_task( + prelude.task_slot_key, + result=prelude.task.result_summary, + ) + elif coda_summary.task_state is TaskState.PAUSED: + slot = prelude.task_graph.slots.get(prelude.task_slot_key) + if slot is not None: + slot.state = GraphTaskState.QUEUED + slot.result = prelude.task.result_summary + prelude.task_graph.updated_at = time.time() + else: + prelude.task_graph.fail_task( + prelude.task_slot_key, + prelude.task.result_summary, + ) + except Exception: + logger.debug("TaskGraph finalization skipped", exc_info=True) logger.info( - "Work chain completed: task=%s state=%s steps=%d errors=%d", - task.id, task.state.value, step, tool_error_count, + "Work chain completed: task=%s state=%s stop_reason=%s steps=%d errors=%d", + prelude.task.id, + prelude.task.state.value, + coda_summary.stop_reason, + turn_state.step, + turn_state.tool_error_count, ) # 任务后自省:提取经验教训 - if orch and task: + if orch and prelude.task: try: execution_trace: list[dict[str, Any]] = [ - {"type": "tool_call", "count": step}, - {"type": "error", "count": tool_error_count, "content": f"{tool_error_count} errors"} if tool_error_count > 0 else {}, - {"type": "assistant", "steps": step}, + {"type": "tool_call", "count": turn_state.step}, + { + "type": "error", + "count": turn_state.tool_error_count, + "content": f"{turn_state.tool_error_count} errors", + } + if turn_state.tool_error_count > 0 + else {}, + {"type": "assistant", "steps": turn_state.step}, ] orch.reflect_on_task( - task_description=task.raw_input if hasattr(task, 'raw_input') else str(task.id), - step=step, - tool_error_count=tool_error_count, + task_description=( + prelude.task.raw_input + if hasattr(prelude.task, "raw_input") + else str(prelude.task.id) + ), + step=turn_state.step, + tool_error_count=turn_state.tool_error_count, execution_trace=execution_trace, ) except Exception: pass - elif reflection_engine and task: + elif reflection_engine and prelude.task: try: execution_trace: list[dict[str, Any]] = [ - {"type": "tool_call", "count": step}, - {"type": "error", "count": tool_error_count, "content": f"{tool_error_count} errors"} if tool_error_count > 0 else {}, - {"type": "assistant", "steps": step}, + {"type": "tool_call", "count": turn_state.step}, + { + "type": "error", + "count": turn_state.tool_error_count, + "content": f"{turn_state.tool_error_count} errors", + } + if turn_state.tool_error_count > 0 + else {}, + {"type": "assistant", "steps": turn_state.step}, ] reflection = reflection_engine.reflect( - task_description=task.raw_input if hasattr(task, 'raw_input') else str(task.id), + task_description=( + prelude.task.raw_input + if hasattr(prelude.task, "raw_input") + else str(prelude.task.id) + ), execution_trace=execution_trace, ) logger.info( @@ -1491,7 +1981,9 @@ def run_agent_turn( if scope in _mgr.memories: entry = _mgr.memories[scope]._id_index.get(mem.id) if entry: - entry.usage_count += (2 if tool_error_count == 0 else -1) + entry.usage_count += ( + 2 if turn_state.tool_error_count == 0 else -1 + ) entry.last_accessed = time.time() break entry.last_accessed = time.time() @@ -1502,15 +1994,21 @@ def run_agent_turn( pass # 路由反馈学习:记录任务结果以优化未来路由 - if smart_router and task: + if smart_router and prelude.task: try: outcome = TaskOutcome( - task_text=task.raw_input if hasattr(task, 'raw_input') else str(task.id), - assigned_model=model.model_id if hasattr(model, 'model_id') else "unknown", - success=(tool_error_count == 0), - duration_ms=step * 2000.0, + task_text=( + prelude.task.raw_input + if hasattr(prelude.task, "raw_input") + else str(prelude.task.id) + ), + assigned_model=( + model.model_id if hasattr(model, "model_id") else "unknown" + ), + success=(turn_state.tool_error_count == 0), + duration_ms=turn_state.step * 2000.0, cost_usd=0.0, - tool_errors=tool_error_count, + tool_errors=turn_state.tool_error_count, model_switches=model_switcher.switch_count() if model_switcher else 0, ) smart_router.learner().record_outcome(outcome) @@ -1518,10 +2016,12 @@ def run_agent_turn( pass # 控制论反馈:记录模式有效性 - if enable_work_chain and feedback_controller and task: - pattern_id = f"{task_metadata.get('intent_type', 'unknown')}_{task.id}" + if enable_work_chain and feedback_controller and prelude.task: + pattern_id = ( + f"{prelude.task_metadata.get('intent_type', 'unknown')}_{prelude.task.id}" + ) feedback_controller.record_pattern_effectiveness( - pattern_id, tool_error_count == 0 + pattern_id, turn_state.tool_error_count == 0 ) # 稳定性监测:记录快照 @@ -1529,7 +2029,7 @@ def run_agent_turn( from minicode.stability_monitor import MetricSnapshot snapshot = MetricSnapshot( timestamp=time.time(), - error_rate=float(tool_error_count) / max(step, 1), + error_rate=float(turn_state.tool_error_count) / max(turn_state.step, 1), avg_latency=step * 2.0, # 简化估算 context_usage=context_manager.get_stats().usage_percentage if context_manager else 0.0, active_tasks=1, diff --git a/minicode/anthropic_adapter.py b/minicode/anthropic_adapter.py index ac4e15e..adba143 100644 --- a/minicode/anthropic_adapter.py +++ b/minicode/anthropic_adapter.py @@ -61,6 +61,15 @@ def _extract_error_message(data: Any, status: int) -> str: return f"Model request failed: {status}" +def _messages_endpoint(base_url: str) -> str: + normalized = base_url.rstrip("/") + if normalized.endswith("/v1/messages"): + return normalized + if normalized.endswith("/v1"): + return normalized + "/messages" + return normalized + "/v1/messages" + + def _parse_assistant_text(content: str) -> tuple[str, str | None]: trimmed = content.strip() if not trimmed: @@ -187,7 +196,7 @@ def next(self, messages: list[dict[str, Any]], on_stream_chunk: Callable[[str], request_body["stream"] = True request = urllib.request.Request( - url=self.runtime["baseUrl"].rstrip("/") + "/v1/messages", + url=_messages_endpoint(self.runtime["baseUrl"]), data=json.dumps(request_body).encode("utf-8"), headers={ "content-type": "application/json", @@ -203,12 +212,14 @@ def next(self, messages: list[dict[str, Any]], on_stream_chunk: Callable[[str], max_retries = _get_retry_limit() response = None + last_exception: Exception | None = None for attempt in range(max_retries + 1): try: timeout = int(os.environ.get("MINICODE_MODEL_TIMEOUT", "60")) response = urllib.request.urlopen(request, timeout=timeout) break except urllib.error.HTTPError as error: + last_exception = error response = error if error.code not in RETRYABLE_STATUS or attempt >= max_retries: break @@ -219,11 +230,17 @@ def next(self, messages: list[dict[str, Any]], on_stream_chunk: Callable[[str], wait = calculate_backoff(attempt, retry_after=retry_after, category=category) time.sleep(wait) - except urllib.error.URLError: + except urllib.error.URLError as error: + last_exception = error if attempt >= max_retries: - raise + break wait = calculate_backoff(attempt) time.sleep(wait) + if response is None: + if last_exception is not None: + raise RuntimeError( + f"Model request failed before receiving a response: {last_exception}" + ) from last_exception raise RuntimeError("Model request failed before receiving a response") if not on_stream_chunk: diff --git a/minicode/cli_commands.py b/minicode/cli_commands.py index 883a2f2..bf90626 100644 --- a/minicode/cli_commands.py +++ b/minicode/cli_commands.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path from minicode.config import ( CLAUDE_SETTINGS_PATH, @@ -10,6 +11,25 @@ load_runtime_config, save_mini_code_settings, ) +from minicode.product_surfaces import ( + build_product_snapshot, + extension_manifest_payload, + resolve_extension_manifest, + set_extension_enabled, +) +from minicode.session import ( + format_rewind_preview, + format_session_checkpoints, + format_session_inspect, + format_session_list, + format_session_replay, + format_session_resume, + get_latest_session, + list_sessions, + load_session, + rewind_session, + rewind_session_data, +) @dataclass(frozen=True, slots=True) @@ -33,6 +53,25 @@ class SlashCommand: SlashCommand("/history", "/history", "Show recent prompt history from ~/.mini-code/history.json."), SlashCommand("/clear", "/clear", "Clear the current transcript view."), SlashCommand("/retry", "/retry", "Retry the last natural-language prompt in this session."), + SlashCommand("/session", "/session", "Inspect the active session, runtime, checkpoints, and recent transcript."), + SlashCommand("/session", "/session ", "Inspect a saved session for the current workspace."), + SlashCommand("/session-replay", "/session-replay", "Replay the active session with checkpoint, history, and transcript timeline."), + SlashCommand("/session-replay", "/session-replay ", "Replay a saved session for the current workspace."), + SlashCommand("/sessions", "/sessions", "List saved sessions for the current workspace."), + SlashCommand("/instructions", "/instructions", "Inspect the active instruction layering surface."), + SlashCommand("/hooks", "/hooks", "Inspect active hooks and recent hook telemetry."), + SlashCommand("/delegation", "/delegation", "Inspect background delegation capacity and running tasks."), + SlashCommand("/extensions", "/extensions", "Inspect local extension manifests for this workspace."), + SlashCommand("/extension-inspect", "/extension-inspect ", "Inspect a local extension manifest and source path."), + SlashCommand("/extension-enable", "/extension-enable ", "Enable a local extension manifest."), + SlashCommand("/extension-disable", "/extension-disable ", "Disable a local extension manifest."), + SlashCommand("/readiness", "/readiness", "Inspect provider/runtime readiness for the current workspace."), + SlashCommand("/checkpoints", "/checkpoints", "List checkpoints for the active session."), + SlashCommand("/checkpoints", "/checkpoints ", "List checkpoints for a saved session in the current workspace."), + SlashCommand("/rewind-preview", "/rewind-preview [latest|steps|checkpoint-id]", "Preview checkpointed file edits that would be rewound for the active session."), + SlashCommand("/rewind", "/rewind [latest|steps|checkpoint-id]", "Rewind checkpointed file edits for the active session."), + SlashCommand("/session-rewind-preview", "/session-rewind-preview [latest|steps|checkpoint-id]", "Preview checkpointed file edits that would be rewound for a saved session."), + SlashCommand("/session-rewind", "/session-rewind [latest|steps|checkpoint-id]", "Rewind checkpointed file edits for a saved session in the current workspace."), SlashCommand("/transcript-save", "/transcript-save ", "Save the current session transcript to a text file."), SlashCommand("/model", "/model", "Show the current model."), SlashCommand("/model", "/model ", "Persist a model override into ~/.mini-code/settings.json."), @@ -94,6 +133,25 @@ def format_slash_commands() -> str: ("/modify ", "Replace file with reviewable diff"), ], "💾 Session Management": [ + ("/session", "Inspect current session state"), + ("/session ", "Inspect saved session or latest"), + ("/session-replay", "Replay active session timeline"), + ("/session-replay ", "Replay saved session timeline"), + ("/sessions", "List saved sessions for workspace"), + ("/instructions", "Inspect active instruction layering"), + ("/hooks", "Inspect hook telemetry and failures"), + ("/delegation", "Inspect background task capacity"), + ("/extensions", "Inspect local extension manifests"), + ("/extension-inspect ", "Inspect one extension in detail"), + ("/extension-enable ", "Enable a local extension"), + ("/extension-disable ", "Disable a local extension"), + ("/readiness", "Inspect provider/runtime readiness"), + ("/checkpoints", "List active session checkpoints"), + ("/checkpoints ", "List saved session checkpoints"), + ("/rewind-preview [arg]", "Preview active session rewind plan"), + ("/rewind [arg]", "Rewind active session file edits"), + ("/session-rewind-preview [arg]", "Preview saved session rewind plan"), + ("/session-rewind [arg]", "Rewind saved session file edits"), ("/transcript-save ", "Save transcript to text file"), ("/retry", "Retry the last prompt"), ("/permissions", "Show permission storage path"), @@ -143,7 +201,279 @@ def complete_slash_command(line: str) -> tuple[list[str], str]: return (hits if hits else commands, line) -def try_handle_local_command(user_input: str, tools=None, cwd: str | None = None) -> str | None: +def try_handle_local_command( + user_input: str, + tools=None, + cwd: str | None = None, + session=None, +) -> str | None: + def _product_snapshot() -> dict: + if session is not None: + instruction_layers = list(getattr(session, "instruction_layers", []) or []) + hook_status = dict(getattr(session, "hook_status", {}) or {}) + delegated_tasks = list(getattr(session, "delegated_tasks", []) or []) + delegation_status = dict(getattr(session, "delegation_status", {}) or {}) + extension_manifests = list(getattr(session, "extension_manifests", []) or []) + readiness_report = dict(getattr(session, "readiness_report", {}) or {}) + if any( + [ + instruction_layers, + hook_status, + delegated_tasks, + delegation_status, + extension_manifests, + readiness_report, + ] + ): + metadata = getattr(session, "metadata", None) + return { + "instruction_layers": instruction_layers, + "instruction_summary": getattr(metadata, "instruction_summary", ""), + "hook_status": hook_status, + "hook_summary": getattr(metadata, "hook_summary", ""), + "delegated_tasks": delegated_tasks, + "delegation_status": delegation_status, + "delegation_summary": getattr(metadata, "delegation_summary", ""), + "extension_manifests": extension_manifests, + "extension_summary": getattr(metadata, "extension_summary", ""), + "readiness_report": readiness_report, + "readiness_summary": getattr(metadata, "readiness_summary", ""), + } + if cwd is None: + return {} + return build_product_snapshot(cwd) + + def _format_instruction_surface(snapshot: dict) -> str: + layers = list(snapshot.get("instruction_layers", []) or []) + lines = [ + "Instruction surface:", + snapshot.get("instruction_summary", "instructions: unavailable"), + ] + if not layers: + lines.append("No instruction layers discovered for this workspace.") + return "\n".join(lines) + lines.append("") + lines.append(f"Layers ({len(layers)}):") + for layer in layers: + scope = str(layer.get("scope") or "unknown") + kind = str(layer.get("kind") or "unknown") + exists = "active" if layer.get("exists") else "missing" + path = str(layer.get("path") or "") + preview = str(layer.get("preview") or "") + detail = f"- {scope}/{kind}: {exists}" + if path: + detail += f" [{path}]" + lines.append(detail) + if preview: + lines.append(f" preview: {preview}") + return "\n".join(lines) + + def _format_hook_surface(snapshot: dict) -> str: + status = dict(snapshot.get("hook_status", {}) or {}) + lines = [ + "Hook surface:", + snapshot.get("hook_summary", "hooks: unavailable"), + ] + if not status: + lines.append("No hook telemetry is available.") + return "\n".join(lines) + lines.extend( + [ + "", + f"Registered hooks: {status.get('enabled_hooks', 0)}/{status.get('total_hooks', 0)} enabled", + f"Calls: {status.get('total_calls', 0)}", + f"Duration: {status.get('total_duration_ms', 0)}ms", + ] + ) + failure_count = status.get("failure_count") + last_status = status.get("last_status") + last_error = status.get("last_error") + if failure_count is not None: + lines.append(f"Failures: {failure_count}") + if last_status: + lines.append(f"Last status: {last_status}") + if last_error: + lines.append(f"Last error: {last_error}") + return "\n".join(lines) + + def _format_delegation_surface(snapshot: dict) -> str: + status = dict(snapshot.get("delegation_status", {}) or {}) + tasks = list(snapshot.get("delegated_tasks", []) or []) + lines = [ + "Delegation surface:", + snapshot.get("delegation_summary", "delegation: unavailable"), + ] + if not status and not tasks: + lines.append("No delegation state is available.") + return "\n".join(lines) + if status: + lines.extend( + [ + "", + f"Running tasks: {status.get('running_tasks', 0)}", + f"Tracked tasks: {status.get('total_tracked', 0)}", + f"Slots: {status.get('available_slots', 0)}/{status.get('max_slots', 0)} free", + ] + ) + labels = list(status.get("active_labels", []) or []) + if labels: + lines.append(f"Active labels: {', '.join(str(label) for label in labels)}") + if tasks: + lines.append("") + lines.append(f"Tracked task details ({min(len(tasks), 5)} shown):") + for task in tasks[:5]: + label = str(task.get("label") or task.get("command") or task.get("taskId") or "task") + task_status = str(task.get("status") or "unknown") + lines.append(f"- {label} [{task_status}]") + return "\n".join(lines) + + def _format_extension_surface(snapshot: dict) -> str: + manifests = list(snapshot.get("extension_manifests", []) or []) + lines = [ + "Extension surface:", + snapshot.get("extension_summary", "extensions: unavailable"), + ] + if not manifests: + lines.append("No extension manifests were discovered.") + return "\n".join(lines) + lines.append("") + lines.append(f"Extensions ({len(manifests)}):") + for manifest in manifests: + name = str(manifest.get("name") or "extension") + scope = str(manifest.get("scope") or "unknown") + enabled = "enabled" if manifest.get("enabled", True) else "disabled" + version = str(manifest.get("version") or "").strip() + detail = f"- {name} [{scope}, {enabled}]" + if version: + detail += f" v{version}" + lines.append(detail) + description = str(manifest.get("description") or "").strip() + entrypoint = str(manifest.get("entrypoint") or "").strip() + if description: + lines.append(f" {description}") + if entrypoint: + lines.append(f" entrypoint: {entrypoint}") + return "\n".join(lines) + + def _format_readiness_surface(snapshot: dict) -> str: + report = dict(snapshot.get("readiness_report", {}) or {}) + lines = [ + "Readiness surface:", + snapshot.get("readiness_summary", "readiness: unavailable"), + ] + if not report: + lines.append("No readiness report is available.") + return "\n".join(lines) + status = str(report.get("status") or "unknown") + provider = str(report.get("provider") or "unknown") + provider_ready = bool(report.get("provider_ready")) + fallback_ready = bool(report.get("fallback_ready")) + fallback_candidates = [ + str(candidate) + for candidate in list(report.get("fallback_candidates", []) or []) + if str(candidate).strip() + ] + viable_fallbacks = [ + str(candidate) + for candidate in list(report.get("viable_fallbacks", []) or []) + if str(candidate).strip() + ] + lines.extend( + [ + "", + f"Status: {status}", + f"Provider: {provider}", + f"Provider ready: {'yes' if provider_ready else 'no'}", + f"Channel: {str(report.get('provider_channel') or 'unknown')}", + f"Fallback ready: {'yes' if fallback_ready else 'no'}", + ] + ) + if fallback_candidates: + lines.append( + f"Configured fallbacks ({len(viable_fallbacks)}/{len(fallback_candidates)} locally ready):" + ) + for candidate in fallback_candidates: + label = "ready" if candidate in viable_fallbacks else "not-ready" + lines.append(f"- {candidate} [{label}]") + issues = [str(issue) for issue in list(report.get("issues", []) or []) if str(issue).strip()] + if issues: + lines.append("Issues:") + lines.extend(f"- {issue}" for issue in issues) + guidance = [ + str(item) + for item in list(report.get("fallback_guidance", []) or []) + if str(item).strip() + ] + if guidance: + lines.append("Guidance:") + lines.extend(f"- {item}" for item in guidance) + return "\n".join(lines) + + def _format_extension_manifest_detail(identifier: str) -> str: + if cwd is None: + return "No workspace is available for extension inspection." + try: + manifest = resolve_extension_manifest(cwd, identifier) + payload = extension_manifest_payload(manifest) + except ValueError as exc: + return str(exc) + lines = [ + f"Extension inspect: {manifest.name}", + f"Scope: {manifest.scope}", + f"Enabled: {'yes' if manifest.enabled else 'no'}", + f"Manifest: {manifest.path}", + ] + if manifest.version: + lines.append(f"Version: {manifest.version}") + if manifest.description: + lines.append(f"Description: {manifest.description}") + if manifest.entrypoint: + entrypoint = Path(manifest.path).parent / manifest.entrypoint + exists = "yes" if entrypoint.exists() else "no" + lines.append(f"Entrypoint: {manifest.entrypoint}") + lines.append(f"Entrypoint path: {entrypoint}") + lines.append(f"Entrypoint exists: {exists}") + extra_keys = sorted( + key for key in payload.keys() + if key not in {"name", "version", "description", "enabled", "entrypoint"} + ) + if extra_keys: + lines.append("Extra manifest keys:") + lines.extend(f"- {key}" for key in extra_keys) + return "\n".join(lines) + + def _set_extension_state(identifier: str, enabled: bool) -> str: + if cwd is None: + return "No workspace is available for extension changes." + try: + manifest = set_extension_enabled(cwd, identifier, enabled) + except ValueError as exc: + return str(exc) + status = "enabled" if enabled else "disabled" + return ( + f"Extension {manifest.scope}:{manifest.name} is now {status}.\n\n" + f"{_format_extension_manifest_detail(f'{manifest.scope}:{manifest.name}')}" + ) + + def _format_rewind_result(target_session, restored, prefix: str) -> str: + restored_preview = ", ".join( + f"[{item.checkpoint_id[:8]}] {Path(item.file_path).name or item.file_path}" + for item in restored + ) + return ( + f"{prefix} {len(restored)} checkpoint(s) for session {target_session.session_id[:8]}.\n" + f"Restored: {restored_preview}\n\n" + f"{format_session_resume(target_session)}" + ) + + def _workspace_session(target: str): + workspace = str(Path(cwd).resolve()) if cwd else None + return ( + get_latest_session(workspace=workspace) + if target == "latest" + else load_session(target) + ) + if user_input in {"/", "/help"}: return format_slash_commands() @@ -160,6 +490,195 @@ def try_handle_local_command(user_input: str, tools=None, cwd: str | None = None if user_input == "/permissions": return f"permission store: {MINI_CODE_PERMISSIONS_PATH}" + if user_input == "/sessions": + workspace = str(Path(cwd).resolve()) if cwd else None + sessions = list_sessions() + if workspace is not None: + sessions = [meta for meta in sessions if meta.workspace == workspace] + return format_session_list(sessions) + + if user_input == "/instructions": + return _format_instruction_surface(_product_snapshot()) + + if user_input == "/hooks": + return _format_hook_surface(_product_snapshot()) + + if user_input == "/delegation": + return _format_delegation_surface(_product_snapshot()) + + if user_input == "/extensions": + return _format_extension_surface(_product_snapshot()) + + if user_input.startswith("/extension-inspect "): + identifier = user_input[len("/extension-inspect ") :].strip() + if not identifier: + return "Usage: /extension-inspect " + return _format_extension_manifest_detail(identifier) + + if user_input.startswith("/extension-enable "): + identifier = user_input[len("/extension-enable ") :].strip() + if not identifier: + return "Usage: /extension-enable " + return _set_extension_state(identifier, True) + + if user_input.startswith("/extension-disable "): + identifier = user_input[len("/extension-disable ") :].strip() + if not identifier: + return "Usage: /extension-disable " + return _set_extension_state(identifier, False) + + if user_input == "/readiness": + return _format_readiness_surface(_product_snapshot()) + + if user_input == "/session": + if session is None: + return "No active session." + return format_session_inspect(session) + + if user_input == "/session-replay": + if session is None: + return "No active session." + return format_session_replay(session) + + if user_input == "/checkpoints": + if session is None: + return "No active session." + return format_session_checkpoints(session) + + if user_input.startswith("/session "): + target = user_input[len("/session ") :].strip() + if not target: + return "Usage: /session " + if session is not None and target == getattr(session, "session_id", None): + return format_session_inspect(session) + target_session = _workspace_session(target) + if target_session is None: + return "No saved session found for inspection." + return format_session_inspect(target_session) + + if user_input.startswith("/session-replay "): + target = user_input[len("/session-replay ") :].strip() + if not target: + return "Usage: /session-replay " + if session is not None and target == getattr(session, "session_id", None): + return format_session_replay(session) + target_session = _workspace_session(target) + if target_session is None: + return "No saved session found for replay." + return format_session_replay(target_session) + + if user_input.startswith("/checkpoints "): + target = user_input[len("/checkpoints ") :].strip() + if not target: + return "Usage: /checkpoints " + if session is not None and target == getattr(session, "session_id", None): + return format_session_checkpoints(session) + target_session = _workspace_session(target) + if target_session is None: + return "No saved session found for checkpoint inspection." + return format_session_checkpoints(target_session) + + if user_input == "/rewind-preview" or user_input.startswith("/rewind-preview "): + if session is None: + return "No active session." + target = user_input[len("/rewind-preview") :].strip() + steps = 1 + checkpoint_id = None + if target and target != "latest": + if target.isdigit(): + steps = max(1, int(target)) + else: + checkpoint_id = target + return format_rewind_preview( + session, + steps=steps, + checkpoint_id=checkpoint_id, + ) + + if user_input == "/rewind" or user_input.startswith("/rewind "): + if session is None: + return "No active session." + target = user_input[len("/rewind") :].strip() + steps = 1 + checkpoint_id = None + if target and target != "latest": + if target.isdigit(): + steps = max(1, int(target)) + else: + checkpoint_id = target + restored = rewind_session_data( + session, + steps=steps, + checkpoint_id=checkpoint_id, + ) + if not restored: + return "No checkpoints available to rewind." + return _format_rewind_result(session, restored, "Rewound") + + if user_input.startswith("/session-rewind "): + raw = user_input[len("/session-rewind ") :].strip() + if not raw: + return "Usage: /session-rewind [latest|steps|checkpoint-id]" + parts = raw.split(maxsplit=1) + target = parts[0] + rewind_arg = parts[1].strip() if len(parts) > 1 else "latest" + steps = 1 + checkpoint_id = None + if rewind_arg and rewind_arg != "latest": + if rewind_arg.isdigit(): + steps = max(1, int(rewind_arg)) + else: + checkpoint_id = rewind_arg + if session is not None and target == getattr(session, "session_id", None): + restored = rewind_session_data( + session, + steps=steps, + checkpoint_id=checkpoint_id, + ) + if not restored: + return "No checkpoints available to rewind for that session." + return _format_rewind_result(session, restored, "Rewound") + target_session = _workspace_session(target) + if target_session is None: + return "No saved session found to rewind." + rewound_session, restored = rewind_session( + target_session.session_id, + steps=steps, + checkpoint_id=checkpoint_id, + ) + if rewound_session is None or not restored: + return "No checkpoints available to rewind for that session." + return _format_rewind_result(rewound_session, restored, "Rewound") + + if user_input.startswith("/session-rewind-preview "): + raw = user_input[len("/session-rewind-preview ") :].strip() + if not raw: + return "Usage: /session-rewind-preview [latest|steps|checkpoint-id]" + parts = raw.split(maxsplit=1) + target = parts[0] + rewind_arg = parts[1].strip() if len(parts) > 1 else "latest" + steps = 1 + checkpoint_id = None + if rewind_arg and rewind_arg != "latest": + if rewind_arg.isdigit(): + steps = max(1, int(rewind_arg)) + else: + checkpoint_id = rewind_arg + if session is not None and target == getattr(session, "session_id", None): + return format_rewind_preview( + session, + steps=steps, + checkpoint_id=checkpoint_id, + ) + target_session = _workspace_session(target) + if target_session is None: + return "No saved session found to preview." + return format_rewind_preview( + target_session, + steps=steps, + checkpoint_id=checkpoint_id, + ) + if user_input == "/skills": skills = tools.get_skills() if tools else [] if not skills: @@ -184,7 +703,6 @@ def try_handle_local_command(user_input: str, tools=None, cwd: str | None = None # Memory system display try: from minicode.memory import MemoryManager - from pathlib import Path memory_mgr = MemoryManager(project_root=Path(cwd) if cwd else Path.cwd()) return memory_mgr.format_stats() except Exception as e: diff --git a/minicode/config.py b/minicode/config.py index e792577..47dae49 100644 --- a/minicode/config.py +++ b/minicode/config.py @@ -13,6 +13,8 @@ MINI_CODE_PERMISSIONS_PATH = MINI_CODE_DIR / "permissions.json" MINI_CODE_MCP_PATH = MINI_CODE_DIR / "mcp.json" MINI_CODE_USER_PROFILE_PATH = MINI_CODE_DIR / "USER.md" +MINI_CODE_MANAGED_POLICY_PATH = MINI_CODE_DIR / "MANAGED.md" +MINI_CODE_EXTENSIONS_DIR = MINI_CODE_DIR / "extensions" CLAUDE_SETTINGS_PATH = Path.home() / ".claude" / "settings.json" @@ -20,6 +22,16 @@ def project_user_profile_path(cwd: str | Path | None = None) -> Path: """Return the project-level USER.md path.""" return Path(cwd or Path.cwd()) / ".mini-code" / "USER.md" + +def project_managed_policy_path(cwd: str | Path | None = None) -> Path: + """Return the project-level MANAGED.md path.""" + return Path(cwd or Path.cwd()) / ".mini-code" / "MANAGED.md" + + +def project_extensions_dir(cwd: str | Path | None = None) -> Path: + """Return the project-level extensions directory.""" + return Path(cwd or Path.cwd()) / ".mini-code" / "extensions" + # 已知的合法模型名称(用于拼写检查提示) KNOWN_MODELS = [ "claude-sonnet-4-20250514", @@ -47,6 +59,244 @@ def project_user_profile_path(cwd: str | Path | None = None) -> Path: ] +def _coerce_model_list(value: Any) -> list[str]: + if isinstance(value, str): + items = value.split(",") + elif isinstance(value, (list, tuple, set)): + items = list(value) + else: + return [] + ordered: list[str] = [] + seen: set[str] = set() + for item in items: + normalized = str(item or "").strip() + if not normalized or normalized in seen: + continue + seen.add(normalized) + ordered.append(normalized) + return ordered + + +def configured_model_fallbacks( + runtime: dict[str, Any] | None, + provider_name: str | None = None, +) -> list[str]: + runtime = runtime or {} + candidates = _coerce_model_list(runtime.get("fallbackModels")) + provider_key = (provider_name or "").strip().lower() + provider_specific_keys = { + "anthropic": "anthropicFallbackModels", + "openai": "openaiFallbackModels", + "openrouter": "openrouterFallbackModels", + "custom": "customFallbackModels", + } + if provider_key in provider_specific_keys: + candidates.extend(_coerce_model_list(runtime.get(provider_specific_keys[provider_key]))) + ordered: list[str] = [] + seen: set[str] = set() + for candidate in candidates: + if candidate in seen: + continue + seen.add(candidate) + ordered.append(candidate) + return ordered + + +def default_model_fallbacks( + runtime: dict[str, Any] | None, + provider_name: str | None = None, + current_model: str | None = None, +) -> list[str]: + runtime = runtime or {} + provider_key = (provider_name or "").strip().lower() + active_model = str(current_model or runtime.get("model", "")).strip() + candidates: list[str] = [] + + has_openai = bool(runtime.get("openaiApiKey")) and _is_valid_http_url(runtime.get("openaiBaseUrl")) + has_openrouter = bool(runtime.get("openrouterApiKey")) and _is_valid_http_url(runtime.get("openrouterBaseUrl")) + + if provider_key == "anthropic": + sonnet_default = str(runtime.get("anthropicDefaultSonnetModel") or "claude-sonnet-4-20250514").strip() + haiku_default = str(runtime.get("anthropicDefaultHaikuModel") or "claude-haiku-3-20240307").strip() + if active_model == "claude-opus-4-20250514": + candidates.extend([sonnet_default, haiku_default]) + elif active_model == "claude-haiku-3-20240307": + candidates.append(sonnet_default) + elif active_model.startswith("claude-"): + candidates.append(haiku_default) + else: + if has_openai: + candidates.extend(["gpt-4o", "gpt-4o-mini"]) + if has_openrouter: + candidates.append("openrouter/auto") + elif provider_key == "openai": + if active_model == "gpt-4o-mini": + candidates.append("gpt-4o") + elif active_model == "gpt-4o": + candidates.append("gpt-4o-mini") + else: + candidates.extend(["gpt-4o", "gpt-4o-mini"]) + if has_openrouter: + candidates.append("openrouter/auto") + elif provider_key == "openrouter": + candidates.append("openrouter/auto") + if has_openai: + candidates.append("gpt-4o-mini") + elif provider_key == "custom": + if has_openai: + candidates.extend(["gpt-4o", "gpt-4o-mini"]) + elif has_openrouter: + candidates.append("openrouter/auto") + + ordered: list[str] = [] + seen: set[str] = set() + for candidate in candidates: + normalized = str(candidate or "").strip() + if not normalized or normalized == active_model or normalized in seen: + continue + seen.add(normalized) + ordered.append(normalized) + return ordered + + +def effective_model_fallbacks( + runtime: dict[str, Any] | None, + provider_name: str | None = None, + current_model: str | None = None, +) -> list[str]: + runtime = runtime or {} + active_model = str(current_model or runtime.get("model", "")).strip() + ordered: list[str] = [] + seen: set[str] = set() + for candidate in [ + *configured_model_fallbacks(runtime, provider_name), + *default_model_fallbacks(runtime, provider_name, current_model=active_model), + ]: + normalized = str(candidate or "").strip() + if not normalized or normalized == active_model or normalized in seen: + continue + seen.add(normalized) + ordered.append(normalized) + return ordered + + +def describe_provider_channel( + runtime: dict[str, Any] | None, + provider_name: str | None = None, +) -> str: + runtime = runtime or {} + provider_key = (provider_name or "").strip().lower() + if not provider_key: + from minicode.model_registry import detect_provider + + provider_key = detect_provider( + str(runtime.get("model", "")).strip(), + runtime, + ).value + + if provider_key == "anthropic": + has_base = _is_valid_http_url(runtime.get("baseUrl")) + has_token = bool(runtime.get("authToken")) + has_key = bool(runtime.get("apiKey")) + if has_base and has_token and has_key: + return "anthropic-compatible via baseUrl/authToken (+ apiKey)" + if has_base and has_token: + return "anthropic-compatible via baseUrl/authToken" + if has_key: + return "anthropic via apiKey" + return "anthropic channel not configured" + + if provider_key == "openai": + if runtime.get("openaiApiKey") and _is_valid_http_url(runtime.get("openaiBaseUrl")): + return "openai via openaiApiKey/openaiBaseUrl" + return "openai channel not configured" + + if provider_key == "openrouter": + if runtime.get("openrouterApiKey") and _is_valid_http_url(runtime.get("openrouterBaseUrl")): + return "openrouter via openrouterApiKey/openrouterBaseUrl" + return "openrouter channel not configured" + + if provider_key == "custom": + if runtime.get("customApiKey") and _is_valid_http_url(runtime.get("customBaseUrl")): + return "custom via customApiKey/customBaseUrl" + return "custom channel not configured" + + return f"{provider_key or 'unknown'} channel" + + +def describe_fallback_guidance( + runtime: dict[str, Any] | None, + provider_name: str | None = None, + current_model: str | None = None, +) -> list[str]: + runtime = runtime or {} + provider_key = (provider_name or "").strip().lower() + if not provider_key: + from minicode.model_registry import detect_provider + + provider_key = detect_provider( + str(current_model or runtime.get("model", "")).strip(), + runtime, + ).value + + active_model = str(current_model or runtime.get("model", "")).strip() + configured = configured_model_fallbacks(runtime, provider_key) + defaults = default_model_fallbacks(runtime, provider_key, current_model=active_model) + guidance: list[str] = [] + + if ( + provider_key == "anthropic" + and bool(runtime.get("authToken")) + and _is_valid_http_url(runtime.get("baseUrl")) + and not runtime.get("apiKey") + ): + guidance.append( + "Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken." + ) + + if not configured: + provider_specific_key = { + "anthropic": "anthropicFallbackModels", + "openai": "openaiFallbackModels", + "openrouter": "openrouterFallbackModels", + "custom": "customFallbackModels", + }.get(provider_key, "fallbackModels") + guidance.append( + f"Add fallbackModels or {provider_specific_key} to enable model failover." + ) + + if provider_key in {"anthropic", "custom"}: + if not runtime.get("openaiApiKey") and not runtime.get("openrouterApiKey") and not runtime.get("customApiKey"): + guidance.append( + "No local fallback credentials are configured for OpenAI, OpenRouter, or custom providers." + ) + elif provider_key == "openai": + if not runtime.get("openrouterApiKey") and not runtime.get("customApiKey"): + guidance.append( + "No local fallback credentials are configured for OpenRouter or custom providers." + ) + elif provider_key == "openrouter": + if not runtime.get("openaiApiKey") and not runtime.get("customApiKey"): + guidance.append( + "No local fallback credentials are configured for OpenAI or custom providers." + ) + + if defaults and not configured: + guidance.append( + "Default failover can activate only when the matching provider credentials are locally configured." + ) + + ordered: list[str] = [] + seen: set[str] = set() + for item in guidance: + normalized = str(item or "").strip() + if not normalized or normalized in seen: + continue + seen.add(normalized) + ordered.append(normalized) + return ordered + + def _suggest_model_name(typed: str) -> str: """根据输入建议最接近的合法模型名称""" if not typed: @@ -206,6 +456,10 @@ def load_runtime_config(cwd: str | Path | None = None) -> dict[str, Any]: # --- User profile paths --- global_user_profile = MINI_CODE_USER_PROFILE_PATH proj_user_profile = project_user_profile_path(cwd) + global_managed_policy = MINI_CODE_MANAGED_POLICY_PATH + proj_managed_policy = project_managed_policy_path(cwd) + global_extensions = MINI_CODE_EXTENSIONS_DIR + proj_extensions = project_extensions_dir(cwd) # --- User preferences from settings (lightweight, not from USER.md) --- user_preferences = effective.get("userPreferences", {}) @@ -217,12 +471,50 @@ def load_runtime_config(cwd: str | Path | None = None) -> dict[str, Any]: str(env.get("MINI_CODE_VERBOSITY", "")).strip() or user_preferences.get("verbosity", "") ) + fallback_models = _coerce_model_list( + os.environ.get("MINI_CODE_MODEL_FALLBACKS", "") + or effective.get("fallbackModels", []) + ) + anthropic_fallback_models = _coerce_model_list( + os.environ.get("ANTHROPIC_MODEL_FALLBACKS", "") + or effective.get("anthropicFallbackModels", []) + ) + openai_fallback_models = _coerce_model_list( + os.environ.get("OPENAI_MODEL_FALLBACKS", "") + or effective.get("openaiFallbackModels", []) + ) + openrouter_fallback_models = _coerce_model_list( + os.environ.get("OPENROUTER_MODEL_FALLBACKS", "") + or effective.get("openrouterFallbackModels", []) + ) + custom_fallback_models = _coerce_model_list( + os.environ.get("CUSTOM_MODEL_FALLBACKS", "") + or effective.get("customFallbackModels", []) + ) return { "model": model, "baseUrl": base_url, "authToken": auth_token, "apiKey": api_key, + "anthropicDefaultSonnetModel": str( + env.get("ANTHROPIC_DEFAULT_SONNET_MODEL") + or effective.get("anthropicDefaultSonnetModel") + or env.get("ANTHROPIC_MODEL") + or effective.get("model", "") + ).strip(), + "anthropicDefaultOpusModel": str( + env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") + or effective.get("anthropicDefaultOpusModel") + or env.get("ANTHROPIC_MODEL") + or effective.get("model", "") + ).strip(), + "anthropicDefaultHaikuModel": str( + env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL") + or effective.get("anthropicDefaultHaikuModel") + or env.get("ANTHROPIC_MODEL") + or effective.get("model", "") + ).strip(), "openaiBaseUrl": openai_base_url, "openaiApiKey": openai_api_key, "openrouterBaseUrl": openrouter_base_url, @@ -233,8 +525,22 @@ def load_runtime_config(cwd: str | Path | None = None) -> dict[str, Any]: "mcpServers": effective.get("mcpServers", {}), "globalUserProfilePath": str(global_user_profile), "projectUserProfilePath": str(proj_user_profile), + "globalManagedPolicyPath": str(global_managed_policy), + "projectManagedPolicyPath": str(proj_managed_policy), + "globalExtensionsDir": str(global_extensions), + "projectExtensionsDir": str(proj_extensions), "responseLanguage": response_language, "responseVerbosity": response_verbosity, + "fallbackModels": fallback_models, + "anthropicFallbackModels": anthropic_fallback_models, + "openaiFallbackModels": openai_fallback_models, + "openrouterFallbackModels": openrouter_fallback_models, + "customFallbackModels": custom_fallback_models, + "runtimeProfile": str( + os.environ.get("MINI_CODE_RUNTIME_PROFILE") + or effective.get("runtimeProfile", "") + or "single" + ).strip().lower(), "toolProfile": str( os.environ.get("MINI_CODE_TOOL_PROFILE") or effective.get("toolProfile", "") diff --git a/minicode/cybernetic_orchestrator.py b/minicode/cybernetic_orchestrator.py index 5879cf6..b8265e0 100644 --- a/minicode/cybernetic_orchestrator.py +++ b/minicode/cybernetic_orchestrator.py @@ -134,8 +134,11 @@ def initialize( self.smart_router = SmartRouter() self.reflection = ReflectionEngine(memory_manager=None) + current_model = str(getattr(model, "model_id", "") or "").strip() + if not current_model: + current_model = str((runtime or {}).get("model", "") or "").strip() self.model_switcher = ModelSwitcher( - current_model=getattr(model, 'model_id', ''), + current_model=current_model, current_runtime=runtime or {}, current_tools=tools, ) diff --git a/minicode/file_review.py b/minicode/file_review.py index a67659d..b6f11b2 100644 --- a/minicode/file_review.py +++ b/minicode/file_review.py @@ -3,6 +3,7 @@ import difflib from pathlib import Path +from minicode.session import create_file_checkpoint from minicode.tooling import ToolContext, ToolResult @@ -44,6 +45,12 @@ def apply_reviewed_file_change( if context.permissions is not None: context.permissions.ensure_edit(str(target), diff) + create_file_checkpoint( + context.session, + file_path=str(target), + existed=target.exists(), + previous_content=previous_content, + ) target.parent.mkdir(parents=True, exist_ok=True) target.write_text(next_content, encoding="utf-8") return ToolResult(ok=True, output=f"Applied reviewed changes to {file_path}") diff --git a/minicode/headless.py b/minicode/headless.py index b27a1ab..2174099 100644 --- a/minicode/headless.py +++ b/minicode/headless.py @@ -99,6 +99,7 @@ def run_headless(prompt: str | None = None) -> str: messages=messages, cwd=cwd, permissions=permissions, + runtime=runtime, ) # Extract last assistant message diff --git a/minicode/hooks.py b/minicode/hooks.py index 222a2d0..b350c57 100644 --- a/minicode/hooks.py +++ b/minicode/hooks.py @@ -113,6 +113,9 @@ class HookRegistration: call_count: int = 0 last_called: float | None = None total_duration_ms: int = 0 + failure_count: int = 0 + last_error: str = "" + last_status: str = "idle" # --------------------------------------------------------------------------- @@ -193,6 +196,8 @@ async def fire(self, event: HookEvent, **kwargs: Any) -> list[Any]: registration.call_count += 1 registration.last_called = time.time() + registration.last_status = "success" + registration.last_error = "" duration_ms = int((time.time() - start_time) * 1000) registration.total_duration_ms += duration_ms @@ -201,6 +206,10 @@ async def fire(self, event: HookEvent, **kwargs: Any) -> list[Any]: except Exception as e: # Don't let hook errors break main flow + registration.failure_count += 1 + registration.last_called = time.time() + registration.last_status = "error" + registration.last_error = str(e) results.append(f"Hook error: {e}") return results @@ -222,6 +231,8 @@ def fire_sync(self, event: HookEvent, **kwargs: Any) -> list[Any]: result = registration.handler(context) registration.call_count += 1 registration.last_called = time.time() + registration.last_status = "success" + registration.last_error = "" duration_ms = int((time.time() - start_time) * 1000) registration.total_duration_ms += duration_ms @@ -229,6 +240,10 @@ def fire_sync(self, event: HookEvent, **kwargs: Any) -> list[Any]: results.append(result) except Exception as e: + registration.failure_count += 1 + registration.last_called = time.time() + registration.last_status = "error" + registration.last_error = str(e) results.append(f"Hook error: {e}") return results @@ -253,6 +268,21 @@ def get_hook_stats(self, event: HookEvent | None = None) -> dict[str, Any]: "enabled_hooks": sum(1 for h in hooks if h.enabled), "total_calls": sum(h.call_count for h in hooks), "total_duration_ms": sum(h.total_duration_ms for h in hooks), + "failure_count": sum(h.failure_count for h in hooks), + "hooks": [ + { + "event": h.event.value, + "description": h.description or getattr(h.handler, "__name__", "hook"), + "enabled": h.enabled, + "is_async": h.is_async, + "call_count": h.call_count, + "failure_count": h.failure_count, + "last_status": h.last_status, + "last_error": h.last_error, + "total_duration_ms": h.total_duration_ms, + } + for h in hooks + ], } def format_hook_status(self) -> str: diff --git a/minicode/main.py b/minicode/main.py index 87cea79..0af136f 100644 --- a/minicode/main.py +++ b/minicode/main.py @@ -13,7 +13,17 @@ from minicode.manage_cli import maybe_handle_management_command from minicode.model_registry import create_model_adapter from minicode.permissions import PermissionManager -from minicode.prompt import build_system_prompt +from minicode.prompt import build_system_prompt_bundle +from minicode.session import ( + format_rewind_preview, + format_session_checkpoints, + format_session_inspect, + format_session_replay, + format_session_resume, + get_latest_session, + load_session, + rewind_session, +) from minicode.tools import create_default_tool_registry from minicode.tooling import ToolContext from minicode.tui.transcript import format_transcript_text @@ -106,6 +116,101 @@ def _save_transcript_file(cwd: str, permissions, transcript: list[TranscriptEntr target.write_text(format_transcript_text(transcript), encoding="utf-8") return str(target) + +def _resolve_target_session(cwd: str, session_id: str | None): + workspace = str(Path(cwd).resolve()) + return ( + get_latest_session(workspace=workspace) + if session_id in (None, "", "latest") + else load_session(session_id) + ) + + +def _handle_list_checkpoints_request(cwd: str, session_id: str | None) -> int: + target_session = _resolve_target_session(cwd, session_id) + if target_session is None: + print("No saved session found for checkpoint inspection.", file=sys.stderr) + return 1 + + print(format_session_checkpoints(target_session)) + return 0 + + +def _handle_inspect_session_request(cwd: str, session_id: str | None) -> int: + target_session = _resolve_target_session(cwd, session_id) + if target_session is None: + print("No saved session found for inspection.", file=sys.stderr) + return 1 + + print(format_session_inspect(target_session)) + return 0 + + +def _handle_replay_session_request(cwd: str, session_id: str | None) -> int: + target_session = _resolve_target_session(cwd, session_id) + if target_session is None: + print("No saved session found for replay.", file=sys.stderr) + return 1 + + print(format_session_replay(target_session)) + return 0 + + +def _handle_rewind_request( + cwd: str, + session_id: str | None, + steps: int, + checkpoint_id: str | None, +) -> int: + target_session = _resolve_target_session(cwd, session_id) + if target_session is None: + print("No saved session found to rewind.", file=sys.stderr) + return 1 + + session, restored = rewind_session( + target_session.session_id, + steps=steps, + checkpoint_id=checkpoint_id, + ) + if session is None or not restored: + print("No checkpoints available to rewind for that session.", file=sys.stderr) + return 1 + + if checkpoint_id: + print( + f"Rewound {len(restored)} checkpoint(s) through {checkpoint_id[:8]} " + f"for session {session.session_id[:8]}." + ) + else: + print(f"Rewound {len(restored)} checkpoint(s) for session {session.session_id[:8]}.") + for checkpoint in restored: + print(f" - [{checkpoint.checkpoint_id[:8]}] {checkpoint.file_path}") + print(format_session_resume(session)) + return 0 + + +def _handle_preview_rewind_request( + cwd: str, + session_id: str | None, + steps: int, + checkpoint_id: str | None, +) -> int: + target_session = _resolve_target_session(cwd, session_id) + if target_session is None: + print("No saved session found to preview.", file=sys.stderr) + return 1 + + preview = format_rewind_preview( + target_session, + steps=steps, + checkpoint_id=checkpoint_id, + ) + if preview.startswith("No checkpoints available"): + print(preview, file=sys.stderr) + return 1 + print(preview) + return 0 + def main() -> None: _configure_stdio_for_unicode() @@ -132,6 +237,59 @@ def main() -> None: metavar="SESSION_ID", help="Start with a specific session ID", ) + parser.add_argument( + "--rewind", + nargs="?", + const="latest", + default=None, + metavar="SESSION_ID", + help="Rewind the latest checkpointed file edit for a saved session", + ) + parser.add_argument( + "--preview-rewind", + nargs="?", + const="latest", + default=None, + metavar="SESSION_ID", + help="Preview the latest checkpointed file edit that would be rewound for a saved session", + ) + parser.add_argument( + "--list-checkpoints", + nargs="?", + const="latest", + default=None, + metavar="SESSION_ID", + help="List saved rewind checkpoints for a session", + ) + parser.add_argument( + "--inspect-session", + nargs="?", + const="latest", + default=None, + metavar="SESSION_ID", + help="Inspect a saved session with runtime, checkpoint, and transcript summary", + ) + parser.add_argument( + "--replay-session", + nargs="?", + const="latest", + default=None, + metavar="SESSION_ID", + help="Replay a saved session with checkpoint, prompt history, and transcript timeline", + ) + parser.add_argument( + "--rewind-steps", + type=int, + default=1, + metavar="N", + help="Number of checkpoints to rewind when used with --rewind (default: 1)", + ) + parser.add_argument( + "--rewind-to", + default=None, + metavar="CHECKPOINT_ID", + help="Rewind back through a specific checkpoint ID instead of using --rewind-steps", + ) parser.add_argument( "--install", action="store_true", @@ -172,6 +330,35 @@ def main() -> None: cwd = str(Path.cwd()) argv = remaining_argv + + if args.list_checkpoints is not None: + raise SystemExit(_handle_list_checkpoints_request(cwd, args.list_checkpoints)) + + if args.inspect_session is not None: + raise SystemExit(_handle_inspect_session_request(cwd, args.inspect_session)) + + if args.replay_session is not None: + raise SystemExit(_handle_replay_session_request(cwd, args.replay_session)) + + if args.rewind is not None: + raise SystemExit( + _handle_rewind_request( + cwd, + args.rewind, + max(1, args.rewind_steps), + args.rewind_to, + ) + ) + + if args.preview_rewind is not None: + raise SystemExit( + _handle_preview_rewind_request( + cwd, + args.preview_rewind, + max(1, args.rewind_steps), + args.rewind_to, + ) + ) # Filter out our custom args before passing to management commands management_argv = [a for a in argv if not a.startswith("--")] @@ -245,18 +432,20 @@ def main() -> None: ) logger.info("Store initialized with session: %s", app_store.get_state().session_id) + prompt_bundle = build_system_prompt_bundle( + cwd, + permissions.get_summary(), + { + "skills": tools.get_skills(), + "mcpServers": tools.get_mcp_servers(), + "memory_context": memory_mgr.get_relevant_context(), # Inject memory + "runtime": runtime, + }, + ) messages = [ { "role": "system", - "content": build_system_prompt( - cwd, - permissions.get_summary(), - { - "skills": tools.get_skills(), - "mcpServers": tools.get_mcp_servers(), - "memory_context": memory_mgr.get_relevant_context(), # Inject memory - }, - ), + "content": prompt_bundle.prompt, } ] history = load_history_entries() @@ -331,17 +520,19 @@ def main() -> None: messages.append({"role": "user", "content": user_input}) history.append(user_input) save_history_entries(history) + prompt_bundle = build_system_prompt_bundle( + cwd, + permissions.get_summary(), + { + "skills": tools.get_skills(), + "mcpServers": tools.get_mcp_servers(), + "memory_context": memory_mgr.get_relevant_context(query=user_input), + "runtime": runtime, + }, + ) messages[0] = { "role": "system", - "content": build_system_prompt( - cwd, - permissions.get_summary(), - { - "skills": tools.get_skills(), - "mcpServers": tools.get_mcp_servers(), - "memory_context": memory_mgr.get_relevant_context(query=user_input), - }, - ), + "content": prompt_bundle.prompt, } permissions.begin_turn() messages = run_agent_turn( @@ -377,6 +568,8 @@ def main() -> None: list_sessions_only=args.list_sessions, memory_manager=memory_mgr, context_manager=context_mgr, + prompt_bundle=prompt_bundle, + product_snapshot=prompt_bundle.product_snapshot, ) except KeyboardInterrupt: print("\n\nInterrupted by user. Shutting down gracefully...") diff --git a/minicode/model_registry.py b/minicode/model_registry.py index 77b063c..06dacf4 100644 --- a/minicode/model_registry.py +++ b/minicode/model_registry.py @@ -563,8 +563,7 @@ def create_model_adapter( # Anthropic from minicode.anthropic_adapter import AnthropicModelAdapter enriched = dict(runtime or {}) - if "model" not in enriched: - enriched["model"] = model + enriched["model"] = provider_config.model if "baseUrl" not in enriched: enriched["baseUrl"] = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com") if "authToken" not in enriched and "apiKey" not in enriched: diff --git a/minicode/model_switcher.py b/minicode/model_switcher.py index 24dc69f..fb59c56 100644 --- a/minicode/model_switcher.py +++ b/minicode/model_switcher.py @@ -6,19 +6,32 @@ from __future__ import annotations +import os from dataclasses import dataclass, field from typing import Any +from minicode.config import configured_model_fallbacks, default_model_fallbacks from minicode.logging_config import get_logger from minicode.model_registry import ( + ModelSelectionController, + ModelSelectionSignal, BUILTIN_MODELS, + build_provider_config, create_model_adapter, + list_available_models, resolve_model_info, ) logger = get_logger("model_switcher") +_ANTHROPIC_RUNTIME_FAMILY_DEFAULTS = { + "claude-sonnet-4-20250514": "anthropicDefaultSonnetModel", + "claude-opus-4-20250514": "anthropicDefaultOpusModel", + "claude-haiku-3-20240307": "anthropicDefaultHaikuModel", +} + + @dataclass class SwitchResult: """Result of a model switch operation.""" @@ -53,8 +66,23 @@ def __init__( self._runtime = current_runtime self._tools = current_tools self._available_models = available_models or BUILTIN_MODELS + inferred_default_model = "" + try: + if ( + detect_provider_name(current_model) == "anthropic" + and current_model + and not current_model.startswith("claude-") + ): + inferred_default_model = current_model + except Exception: + inferred_default_model = "" + self._runtime_family_defaults = { + key: str((current_runtime or {}).get(key, "") or inferred_default_model).strip() + for key in _ANTHROPIC_RUNTIME_FAMILY_DEFAULTS.values() + } self._switch_history: list[SwitchResult] = [] self._current_adapter: Any = None + self._failed_models: set[str] = set() @property def current_model(self) -> str: @@ -64,17 +92,40 @@ def current_model(self) -> str: def switch_count(self) -> int: return len(self._switch_history) + def sync_current_model(self, model_name: str | None, adapter: Any | None = None) -> None: + """Synchronize switcher state with the active runtime model.""" + normalized = (model_name or "").strip() + if normalized: + self._current_model = normalized + self._runtime["model"] = normalized + self._maybe_seed_runtime_family_defaults(normalized) + if adapter is not None: + self._current_adapter = adapter + + def record_runtime_failure(self, model_name: str | None = None) -> None: + """Mark a model as failed for the current runtime fallback window.""" + normalized = (model_name or self._current_model or "").strip() + if normalized: + self._failed_models.add(normalized) + + def clear_runtime_failures(self) -> None: + """Clear transient runtime failures after a successful model response.""" + self._failed_models.clear() + def switch_to(self, target_model: str, reason: str = "user_request") -> SwitchResult: """Switch to a new model.""" - if target_model not in self._available_models: + if not target_model: + return self.switch_to_fallback(reason=reason) + + if target_model == self._current_model: return SwitchResult( success=False, old_model=self._current_model, new_model=target_model, old_provider=detect_provider_name(self._current_model), - new_provider="unknown", + new_provider=detect_provider_name(target_model), reason=reason, - errors=[f"Model '{target_model}' not in available models"], + errors=["Target model is already active"], ) old_model = self._current_model @@ -120,6 +171,152 @@ def switch_to(self, target_model: str, reason: str = "user_request") -> SwitchRe logger.error("Model switch failed: %s", result.to_log()) return result + def switch_to_fallback(self, reason: str = "fallback") -> SwitchResult: + """Switch to the first viable fallback candidate.""" + old_model = self._current_model + old_provider = detect_provider_name(old_model) + errors: list[str] = [] + candidates = self._fallback_candidates() + + logger.debug( + "Fallback resolution: current=%s failed=%s snapshot_defaults=%s live_defaults=%s candidates=%s", + self._current_model, + sorted(self._failed_models), + self._runtime_family_defaults, + { + key: str(self._runtime.get(key, "") or "").strip() + for key in _ANTHROPIC_RUNTIME_FAMILY_DEFAULTS.values() + }, + candidates, + ) + + for candidate in candidates: + result = self.switch_to(candidate, reason=reason) + if result.success: + return result + if result.errors: + errors.extend(result.errors) + + result = SwitchResult( + success=False, + old_model=old_model, + new_model="", + old_provider=old_provider, + new_provider="unknown", + reason=reason, + errors=errors or ["No viable fallback models were available"], + ) + self._switch_history.append(result) + logger.error("Model fallback failed: %s", result.to_log()) + return result + + def _fallback_candidates(self) -> list[str]: + current_provider = detect_provider_name(self._current_model) + provider_env = f"{current_provider.upper()}_MODEL_FALLBACKS" + explicit_candidates: list[str] = [] + candidates: list[str] = [] + + runtime_candidates = configured_model_fallbacks(self._runtime, current_provider) + explicit_candidates.extend(runtime_candidates) + candidates.extend(runtime_candidates) + candidates.extend( + default_model_fallbacks( + self._runtime, + current_provider, + current_model=self._current_model, + ) + ) + + for env_var in ("MINI_CODE_MODEL_FALLBACKS", provider_env): + parsed = _parse_model_list(os.environ.get(env_var, "")) + explicit_candidates.extend(parsed) + candidates.extend(parsed) + + current_info = resolve_model_info(self._current_model) + candidates.extend( + info.name + for info in list_available_models(current_info.provider) + ) + + if not self._should_limit_cross_provider_fallbacks(explicit_candidates): + try: + decision = ModelSelectionController().decide( + ModelSelectionSignal( + task_complexity=str(self._runtime.get("taskComplexity", "moderate") or "moderate"), + budget_pressure=float(self._runtime.get("budgetPressure", 0.0) or 0.0), + latency_pressure=float(self._runtime.get("latencyPressure", 0.0) or 0.0), + recent_failures=int(self._runtime.get("recentFailures", 0) or 0), + current_model=self._current_model, + ) + ) + if decision.fallback_model: + candidates.append(decision.fallback_model) + candidates.append(decision.model) + except Exception: + pass + + seen: set[str] = set() + ordered: list[str] = [] + for candidate in candidates: + if candidate in explicit_candidates: + normalized = candidate.strip() + else: + normalized = self._resolve_runtime_model_override(candidate) + if ( + not normalized + or normalized == self._current_model + or normalized in self._failed_models + or normalized in seen + or not self._can_attempt_model(normalized) + ): + continue + seen.add(normalized) + ordered.append(normalized) + return ordered + + def _should_limit_cross_provider_fallbacks(self, explicit_candidates: list[str]) -> bool: + try: + current_provider = detect_provider_name(self._current_model) + except Exception: + return False + if current_provider != "anthropic": + return False + if not self._current_model or self._current_model.startswith("claude-"): + return False + if explicit_candidates: + return True + return any(self._runtime_family_defaults.values()) + + def _resolve_runtime_model_override(self, candidate: str) -> str: + normalized = candidate.strip() + if not normalized: + return "" + override_key = _ANTHROPIC_RUNTIME_FAMILY_DEFAULTS.get(normalized) + if not override_key: + return normalized + override_model = self._runtime_family_defaults.get(override_key, "") + if not override_model: + override_model = str(self._runtime.get(override_key, "") or "").strip() + return override_model or normalized + + def _maybe_seed_runtime_family_defaults(self, model_name: str) -> None: + try: + if detect_provider_name(model_name) != "anthropic" or model_name.startswith("claude-"): + return + except Exception: + return + if any(self._runtime_family_defaults.values()): + return + for key in _ANTHROPIC_RUNTIME_FAMILY_DEFAULTS.values(): + self._runtime_family_defaults[key] = model_name + + def _can_attempt_model(self, model_name: str) -> bool: + try: + provider_config = build_provider_config(model_name, self._runtime) + except Exception: + return False + return bool(provider_config.api_key) + def get_switch_history(self) -> list[dict[str, Any]]: """Get human-readable switch history.""" return [ @@ -142,3 +339,7 @@ def detect_provider_name(model: str) -> str: """Get provider name string for a model.""" info = resolve_model_info(model) return info.provider.value + + +def _parse_model_list(raw: str) -> list[str]: + return [item.strip() for item in raw.split(",") if item.strip()] diff --git a/minicode/product_surfaces.py b/minicode/product_surfaces.py new file mode 100644 index 0000000..d18b4ca --- /dev/null +++ b/minicode/product_surfaces.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +from minicode.background_tasks import get_slot_stats, list_background_tasks +from minicode.config import ( + MINI_CODE_EXTENSIONS_DIR, + MINI_CODE_MANAGED_POLICY_PATH, + MINI_CODE_USER_PROFILE_PATH, + configured_model_fallbacks, + describe_fallback_guidance, + describe_provider_channel, + default_model_fallbacks, + effective_model_fallbacks, + load_runtime_config, + project_extensions_dir, + project_managed_policy_path, + project_user_profile_path, + validate_provider_runtime, +) +from minicode.hooks import get_hook_manager +from minicode.model_registry import detect_provider + + +@dataclass(frozen=True, slots=True) +class InstructionLayer: + name: str + scope: str + kind: str + path: str + exists: bool + preview: str = "" + content: str = "" + + +@dataclass(frozen=True, slots=True) +class ExtensionManifest: + name: str + scope: str + path: str + version: str = "" + description: str = "" + enabled: bool = True + entrypoint: str = "" + + +@dataclass(frozen=True, slots=True) +class HookStatus: + total_hooks: int + enabled_hooks: int + total_calls: int + total_duration_ms: int + summary: str + + +@dataclass(frozen=True, slots=True) +class DelegationStatus: + running_tasks: int + total_tracked: int + max_slots: int + available_slots: int + active_labels: list[str] = field(default_factory=list) + summary: str = "" + + +@dataclass(frozen=True, slots=True) +class ReadinessReport: + status: str + provider: str + provider_ready: bool + provider_channel: str = "" + fallback_ready: bool = False + fallback_candidates: list[str] = field(default_factory=list) + viable_fallbacks: list[str] = field(default_factory=list) + fallback_guidance: list[str] = field(default_factory=list) + issues: list[str] = field(default_factory=list) + summary: str = "" + + +@dataclass(frozen=True, slots=True) +class PromptBundle: + prompt: str + instruction_layers: list[InstructionLayer] + instruction_summary: str + hook_status: HookStatus + delegation_status: DelegationStatus + extension_manifests: list[ExtensionManifest] + extension_summary: str + readiness_report: ReadinessReport + readiness_summary: str + product_snapshot: dict[str, Any] + + +def _maybe_read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except OSError: + return "" + + +def _preview_text(content: str, limit: int = 100) -> str: + normalized = " ".join(content.split()) + if not normalized: + return "" + if len(normalized) <= limit: + return normalized + return normalized[: limit - 3] + "..." + + +def _surface_value(item: Any, field_name: str, default: Any = None) -> Any: + if hasattr(item, field_name): + return getattr(item, field_name) + if isinstance(item, dict): + return item.get(field_name, default) + return default + + +def collect_instruction_layers(cwd: str | Path) -> list[InstructionLayer]: + cwd_path = Path(cwd) + candidates = [ + ("global-claude", "global", "claude", Path.home() / ".claude" / "CLAUDE.md"), + ("global-user", "global", "user", MINI_CODE_USER_PROFILE_PATH), + ("global-managed", "global", "managed", MINI_CODE_MANAGED_POLICY_PATH), + ("project-claude", "project", "claude", cwd_path / "CLAUDE.md"), + ("project-user", "project", "user", project_user_profile_path(cwd_path)), + ("project-managed", "project", "managed", project_managed_policy_path(cwd_path)), + ] + layers: list[InstructionLayer] = [] + for name, scope, kind, path in candidates: + content = _maybe_read_text(path) if path.exists() else "" + layers.append( + InstructionLayer( + name=name, + scope=scope, + kind=kind, + path=str(path), + exists=path.exists(), + preview=_preview_text(content), + content=content, + ) + ) + return layers + + +def format_instruction_summary(layers: list[dict[str, Any]] | list[InstructionLayer]) -> str: + usable = [layer for layer in layers if bool(_surface_value(layer, "exists", False))] + if not usable: + return "instructions: no active layers" + tokens = [ + f"{_surface_value(layer, 'scope', 'unknown')}:{_surface_value(layer, 'kind', 'unknown')}" + for layer in usable + ] + return f"instructions: {len(usable)} active layer(s) [{', '.join(tokens)}]" + + +def collect_extension_manifests(cwd: str | Path) -> list[ExtensionManifest]: + manifests: list[ExtensionManifest] = [] + search_roots = extension_search_roots(cwd) + for scope, root in search_roots: + if not root.exists(): + continue + for manifest_path in sorted(root.glob("*/extension.json")): + try: + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + manifests.append( + ExtensionManifest( + name=str(payload.get("name") or manifest_path.parent.name), + scope=scope, + path=str(manifest_path), + version=str(payload.get("version", "") or ""), + description=str(payload.get("description", "") or ""), + enabled=bool(payload.get("enabled", True)), + entrypoint=str(payload.get("entrypoint", "") or ""), + ) + ) + return manifests + + +def extension_search_roots(cwd: str | Path) -> list[tuple[str, Path]]: + return [ + ("global", MINI_CODE_EXTENSIONS_DIR), + ("project", project_extensions_dir(cwd)), + ] + + +def resolve_extension_manifest( + cwd: str | Path, + identifier: str, +) -> ExtensionManifest: + requested = str(identifier or "").strip() + if not requested: + raise ValueError("Extension name is required.") + + scope_filter = "" + name_filter = requested + if ":" in requested: + maybe_scope, remainder = requested.split(":", 1) + maybe_scope = maybe_scope.strip().lower() + if maybe_scope in {"global", "project"}: + scope_filter = maybe_scope + name_filter = remainder.strip() + if not name_filter: + raise ValueError("Extension name is required.") + + matches = [ + manifest + for manifest in collect_extension_manifests(cwd) + if ( + (not scope_filter or manifest.scope == scope_filter) + and manifest.name == name_filter + ) + ] + if not matches: + raise ValueError(f"No extension named '{requested}' was found.") + if len(matches) > 1: + options = ", ".join( + f"{manifest.scope}:{manifest.name}" + for manifest in matches + ) + raise ValueError( + f"Multiple extensions matched '{requested}'. Use one of: {options}" + ) + return matches[0] + + +def extension_manifest_payload(manifest: ExtensionManifest) -> dict[str, Any]: + manifest_path = Path(manifest.path) + try: + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError( + f"Failed to read extension manifest '{manifest.path}': {exc}" + ) from exc + if not isinstance(payload, dict): + raise ValueError(f"Extension manifest '{manifest.path}' is not a JSON object.") + return payload + + +def set_extension_enabled( + cwd: str | Path, + identifier: str, + enabled: bool, +) -> ExtensionManifest: + manifest = resolve_extension_manifest(cwd, identifier) + payload = extension_manifest_payload(manifest) + payload["enabled"] = bool(enabled) + manifest_path = Path(manifest.path) + manifest_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + return resolve_extension_manifest(cwd, f"{manifest.scope}:{manifest.name}") + + +def format_extension_summary( + manifests: list[dict[str, Any]] | list[ExtensionManifest], +) -> str: + if not manifests: + return "extensions: none discovered" + enabled = [ + manifest for manifest in manifests + if bool(_surface_value(manifest, "enabled", False)) + ] + project_count = sum( + 1 for manifest in manifests + if str(_surface_value(manifest, "scope", "")) == "project" + ) + return ( + f"extensions: {len(enabled)}/{len(manifests)} enabled " + f"({project_count} project, {len(manifests) - project_count} global)" + ) + + +def build_hook_status() -> HookStatus: + stats = get_hook_manager().get_hook_stats() + total_hooks = int(stats.get("total_hooks", 0)) + enabled_hooks = int(stats.get("enabled_hooks", 0)) + total_calls = int(stats.get("total_calls", 0)) + total_duration_ms = int(stats.get("total_duration_ms", 0)) + if total_hooks == 0: + summary = "hooks: none registered" + else: + summary = ( + f"hooks: {enabled_hooks}/{total_hooks} enabled, " + f"{total_calls} call(s), {total_duration_ms}ms total" + ) + return HookStatus( + total_hooks=total_hooks, + enabled_hooks=enabled_hooks, + total_calls=total_calls, + total_duration_ms=total_duration_ms, + summary=summary, + ) + + +def build_delegation_status() -> DelegationStatus: + stats = get_slot_stats() + tasks = list_background_tasks() + running = [task for task in tasks if task.get("status") == "running"] + labels = [ + str(task.get("label") or task.get("command") or task.get("taskId") or "task") + for task in running[:3] + ] + summary = ( + f"delegation: {len(running)} running, " + f"{int(stats.get('available_slots', 0))}/{int(stats.get('max_slots', 0))} slots free" + ) + if labels: + summary += f" [{', '.join(labels)}]" + return DelegationStatus( + running_tasks=len(running), + total_tracked=int(stats.get("total_tracked", 0)), + max_slots=int(stats.get("max_slots", 0)), + available_slots=int(stats.get("available_slots", 0)), + active_labels=labels, + summary=summary, + ) + + +def _classify_fallbacks( + runtime: dict[str, Any], + provider: str, +) -> tuple[list[str], list[str], list[str]]: + fallback_candidates = [ + candidate + for candidate in effective_model_fallbacks( + runtime, + provider, + current_model=str(runtime.get("model", "")).strip(), + ) + if candidate != str(runtime.get("model", "")).strip() + ] + viable: list[str] = [] + issues: list[str] = [] + for candidate in fallback_candidates: + candidate_runtime = dict(runtime) + candidate_runtime["model"] = candidate + candidate_issues = validate_provider_runtime(candidate_runtime) + if candidate_issues: + issues.append(f"Fallback '{candidate}' is not locally ready: {candidate_issues[0]}") + continue + viable.append(candidate) + return fallback_candidates, viable, issues + + +def build_readiness_report( + cwd: str | Path, + runtime: dict[str, Any] | None = None, +) -> ReadinessReport: + try: + effective_runtime = runtime or load_runtime_config(cwd) + issues = validate_provider_runtime(effective_runtime) + provider = detect_provider( + str(effective_runtime.get("model", "")).strip(), + effective_runtime, + ).value + provider_ready = not issues + configured_fallbacks = configured_model_fallbacks(effective_runtime, provider) + default_fallbacks = [ + candidate + for candidate in default_model_fallbacks( + effective_runtime, + provider, + current_model=str(effective_runtime.get("model", "")).strip(), + ) + if candidate not in configured_fallbacks + ] + fallback_candidates, viable_fallbacks, fallback_issues = _classify_fallbacks( + effective_runtime, + provider, + ) + provider_channel = describe_provider_channel(effective_runtime, provider) + fallback_guidance = describe_fallback_guidance( + effective_runtime, + provider_name=provider, + current_model=str(effective_runtime.get("model", "")).strip(), + ) + issues.extend(fallback_issues) + except Exception as exc: + effective_runtime = runtime or {} + issues = [str(exc)] + provider = detect_provider( + str(effective_runtime.get("model", "")).strip(), + effective_runtime, + ).value if effective_runtime else "unknown" + provider_ready = False + configured_fallbacks = [] + default_fallbacks = [] + fallback_candidates = [] + viable_fallbacks = [] + provider_channel = describe_provider_channel(effective_runtime, provider) + fallback_guidance = describe_fallback_guidance( + effective_runtime, + provider_name=provider, + current_model=str(effective_runtime.get("model", "")).strip(), + ) + fallback_ready = bool(viable_fallbacks) + if provider_ready and fallback_ready: + status = "ready" + elif provider_ready: + status = "warning" + if fallback_candidates: + if configured_fallbacks and default_fallbacks: + issues.append("Primary provider is ready, but no configured or default fallback model is locally ready.") + elif configured_fallbacks: + issues.append("Primary provider is ready, but no configured fallback model is locally ready.") + else: + issues.append("Primary provider is ready, but no default fallback model is locally ready.") + else: + issues.append("Primary provider is ready, but no configured or default fallback models are available.") + elif fallback_ready: + status = "warning" + if configured_fallbacks and default_fallbacks: + issues.insert(0, "Primary provider is blocked, but at least one configured or default fallback model is locally ready.") + elif configured_fallbacks: + issues.insert(0, "Primary provider is blocked, but at least one configured fallback model is locally ready.") + else: + issues.insert(0, "Primary provider is blocked, but at least one default fallback model is locally ready.") + else: + status = "blocked" + summary = f"readiness: {status} ({provider})" + if fallback_candidates: + summary += f" [fallbacks {len(viable_fallbacks)}/{len(fallback_candidates)} locally ready]" + if issues: + summary += f" [{issues[0]}]" + return ReadinessReport( + status=status, + provider=provider, + provider_ready=provider_ready, + provider_channel=provider_channel, + fallback_ready=fallback_ready, + fallback_candidates=fallback_candidates, + viable_fallbacks=viable_fallbacks, + fallback_guidance=fallback_guidance, + issues=issues, + summary=summary, + ) + + +def build_product_snapshot( + cwd: str | Path, + runtime: dict[str, Any] | None = None, +) -> dict[str, Any]: + instruction_layers = collect_instruction_layers(cwd) + hook_status = build_hook_status() + delegation_status = build_delegation_status() + extension_manifests = collect_extension_manifests(cwd) + readiness_report = build_readiness_report(cwd, runtime=runtime) + return { + "instruction_layers": [asdict(layer) for layer in instruction_layers], + "instruction_summary": format_instruction_summary(instruction_layers), + "hook_status": asdict(hook_status), + "hook_summary": hook_status.summary, + "delegated_tasks": list_background_tasks(), + "delegation_status": asdict(delegation_status), + "delegation_summary": delegation_status.summary, + "extension_manifests": [asdict(manifest) for manifest in extension_manifests], + "extension_summary": format_extension_summary(extension_manifests), + "readiness_report": asdict(readiness_report), + "readiness_summary": readiness_report.summary, + } diff --git a/minicode/prompt.py b/minicode/prompt.py index 39f2696..3b263ca 100644 --- a/minicode/prompt.py +++ b/minicode/prompt.py @@ -3,6 +3,13 @@ from pathlib import Path from minicode.prompt_pipeline import PromptPipeline, read_file_cached +from minicode.product_surfaces import ( + DelegationStatus, + HookStatus, + PromptBundle, + ReadinessReport, + build_product_snapshot, +) def _maybe_read(path: Path) -> str | None: @@ -86,11 +93,11 @@ def _engineering_governance_rules() -> str: - Vendor only imported by port_entry/""" -def build_system_prompt( +def build_system_prompt_bundle( cwd: str, permission_summary: list[str] | None = None, extras: dict | None = None, -) -> str: +) -> PromptBundle: """Build the system prompt using dynamic paragraph assembly. Implements cache boundaries: @@ -105,6 +112,8 @@ def build_system_prompt( cwd_path = Path(cwd) permission_summary = permission_summary or [] extras = extras or {} + runtime = extras.get("runtime") + product_snapshot = build_product_snapshot(cwd, runtime=runtime) pipeline = PromptPipeline() @@ -239,6 +248,66 @@ def _build_mcp(): cache_ttl=30.0, ) + instruction_summary = str(product_snapshot.get("instruction_summary") or "").strip() + if instruction_summary: + pipeline.register_dynamic( + "instructions", + lambda: ( + "## Instruction Layers\n" + "Follow these active instruction layers in precedence order before inventing new behavior.\n" + f"{instruction_summary}" + ), + cache_ttl=60.0, + ) + + hook_summary = str(product_snapshot.get("hook_summary") or "").strip() + if hook_summary: + pipeline.register_dynamic( + "hooks", + lambda: ( + "## Hook Runtime\n" + "Local automation hooks may run around tools, session saves, and runtime transitions.\n" + f"{hook_summary}" + ), + cache_ttl=60.0, + ) + + delegation_summary = str(product_snapshot.get("delegation_summary") or "").strip() + if delegation_summary: + pipeline.register_dynamic( + "delegation", + lambda: ( + "## Delegation Runtime\n" + "Background tasks and delegated work share local slots with this session.\n" + f"{delegation_summary}" + ), + cache_ttl=30.0, + ) + + extension_summary = str(product_snapshot.get("extension_summary") or "").strip() + if extension_summary: + pipeline.register_dynamic( + "extensions", + lambda: ( + "## Extensions\n" + "Treat discovered local extensions as optional product-surface integrations.\n" + f"{extension_summary}" + ), + cache_ttl=120.0, + ) + + readiness_summary = str(product_snapshot.get("readiness_summary") or "").strip() + if readiness_summary: + pipeline.register_dynamic( + "readiness", + lambda: ( + "## Runtime Readiness\n" + "Prefer resilient execution and surface readiness blockers clearly when provider/runtime conditions are degraded.\n" + f"{readiness_summary}" + ), + cache_ttl=30.0, + ) + # Global CLAUDE.md (file-cached) global_claude_md = _maybe_read(Path.home() / ".claude" / "CLAUDE.md") if global_claude_md: @@ -257,4 +326,29 @@ def _build_mcp(): cache_ttl=300.0, ) - return pipeline.build() + instruction_layers = product_snapshot.get("instruction_layers", []) + hook_status = HookStatus(**product_snapshot.get("hook_status", {})) + delegation_status = DelegationStatus(**product_snapshot.get("delegation_status", {})) + extension_manifests = product_snapshot.get("extension_manifests", []) + readiness_report = ReadinessReport(**product_snapshot.get("readiness_report", {})) + + return PromptBundle( + prompt=pipeline.build(), + instruction_layers=instruction_layers, + instruction_summary=instruction_summary, + hook_status=hook_status, + delegation_status=delegation_status, + extension_manifests=extension_manifests, + extension_summary=extension_summary, + readiness_report=readiness_report, + readiness_summary=readiness_summary, + product_snapshot=product_snapshot, + ) + + +def build_system_prompt( + cwd: str, + permission_summary: list[str] | None = None, + extras: dict | None = None, +) -> str: + return build_system_prompt_bundle(cwd, permission_summary, extras).prompt diff --git a/minicode/release_readiness.py b/minicode/release_readiness.py new file mode 100644 index 0000000..190442e --- /dev/null +++ b/minicode/release_readiness.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any + + +@dataclass(frozen=True, slots=True) +class ReleaseCheck: + label: str + command: str + exit_code: int + status: str + summary: str + stdout: str = "" + stderr: str = "" + + +def classify_provider_outcome(*, exit_code: int, stdout: str, stderr: str) -> tuple[str, str]: + stripped_stdout = (stdout or "").strip() + stripped_stderr = (stderr or "").strip() + combined = " ".join(f"{stripped_stdout}\n{stripped_stderr}".lower().split()) + summary_source = stripped_stdout or stripped_stderr + summary = summary_source.splitlines()[0].strip() if summary_source else "" + + if exit_code == 0 and stripped_stdout == "OK": + return "answered", summary or "Headless provider smoke returned OK." + if ( + "provider availability failure" in combined + or "all viable fallback models were unavailable" in combined + or "no available channel" in combined + ): + return "provider_outage", summary or "Provider availability failure." + if "empty response" in combined: + return "empty_output", summary or "Provider smoke returned an empty response." + if exit_code == 124: + return "timeout", summary or "Provider smoke timed out." + return "error", summary or f"Provider smoke failed with exit code {exit_code}." + + +def summarize_release_status( + *, + compile_check: ReleaseCheck, + test_check: ReleaseCheck, + runtime_eval_check: ReleaseCheck, + smoke_checks: list[ReleaseCheck], + provider_outcomes: list[str], + readiness_report: dict[str, Any] | None = None, +) -> str: + if any(check.status == "failed" for check in [compile_check, test_check, runtime_eval_check, *smoke_checks]): + return "blocked" + if any(outcome not in {"answered", "provider_outage", "empty_output"} for outcome in provider_outcomes): + return "at-risk" + if any(outcome == "provider_outage" for outcome in provider_outcomes): + report = dict(readiness_report or {}) + if not report.get("fallback_ready"): + return "at-risk" + return "warning" + return "pass" + + +def release_readiness_as_dict( + *, + generated_at: str, + status: str, + compile_check: ReleaseCheck, + test_check: ReleaseCheck, + runtime_eval_check: ReleaseCheck, + smoke_checks: list[ReleaseCheck], + provider_diagnostics: list[dict[str, Any]], + runtime_profile_artifacts: dict[str, str], + readiness_report: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "generated_at": generated_at, + "status": status, + "compile_check": asdict(compile_check), + "test_check": asdict(test_check), + "runtime_eval_check": asdict(runtime_eval_check), + "smoke_checks": [asdict(item) for item in smoke_checks], + "provider_diagnostics": provider_diagnostics, + "runtime_profile_artifacts": runtime_profile_artifacts, + "readiness_report": dict(readiness_report or {}), + } + + +def release_readiness_as_markdown( + *, + generated_at: str, + status: str, + compile_check: ReleaseCheck, + test_check: ReleaseCheck, + runtime_eval_check: ReleaseCheck, + smoke_checks: list[ReleaseCheck], + provider_diagnostics: list[dict[str, Any]], + runtime_profile_artifacts: dict[str, str], + readiness_report: dict[str, Any] | None = None, +) -> str: + report = dict(readiness_report or {}) + lines = [ + "# MiniCode Release Readiness", + "", + f"- Generated at: {generated_at}", + f"- Status: {status}", + "", + "## Core Gate", + "", + "| check | status | exit_code | summary |", + "| --- | --- | ---: | --- |", + ] + for item in [compile_check, test_check, runtime_eval_check]: + lines.append( + f"| {item.label} | {item.status} | {item.exit_code} | {item.summary} |" + ) + + lines.extend( + [ + "", + "## Product Smokes", + "", + "| check | status | exit_code | summary |", + "| --- | --- | ---: | --- |", + ] + ) + for item in smoke_checks: + lines.append( + f"| {item.label} | {item.status} | {item.exit_code} | {item.summary} |" + ) + + lines.extend( + [ + "", + "## Provider Diagnostics", + "", + "| label | outcome | exit_code | summary |", + "| --- | --- | ---: | --- |", + ] + ) + for item in provider_diagnostics: + lines.append( + f"| {item.get('label', '-')} | {item.get('outcome', '-')} | " + f"{item.get('exit_code', 0)} | {item.get('summary', '')} |" + ) + + if report: + fallback_candidates = [ + str(candidate) + for candidate in list(report.get("fallback_candidates", []) or []) + ] + viable_fallbacks = { + str(candidate) + for candidate in list(report.get("viable_fallbacks", []) or []) + } + lines.extend( + [ + "", + "## Provider Fallback Coverage", + "", + f"- Provider: {report.get('provider', 'unknown')}", + f"- Provider ready: {'yes' if report.get('provider_ready') else 'no'}", + f"- Channel: {report.get('provider_channel', 'unknown')}", + f"- Fallback ready: {'yes' if report.get('fallback_ready') else 'no'}", + f"- Summary: {report.get('summary', '')}", + ] + ) + guidance = [ + str(item) + for item in list(report.get("fallback_guidance", []) or []) + if str(item).strip() + ] + if guidance: + lines.append("- Guidance:") + for item in guidance: + lines.append(f" - {item}") + if fallback_candidates: + lines.append("") + lines.append("| fallback | locally ready |") + lines.append("| --- | --- |") + for candidate in fallback_candidates: + lines.append( + f"| {candidate} | {'yes' if candidate in viable_fallbacks else 'no'} |" + ) + + lines.extend( + [ + "", + "## Runtime Profile Artifacts", + "", + f"- JSON: {runtime_profile_artifacts.get('json', '-')}", + f"- Markdown: {runtime_profile_artifacts.get('markdown', '-')}", + ] + ) + return "\n".join(lines) diff --git a/minicode/runtime_profile_eval.py b/minicode/runtime_profile_eval.py new file mode 100644 index 0000000..f5dc4d2 --- /dev/null +++ b/minicode/runtime_profile_eval.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import time +from dataclasses import asdict, dataclass +from typing import Any, Callable + +from minicode.agent_loop import run_agent_turn +from minicode.tooling import ToolRegistry +from minicode.types import ChatMessage, ModelAdapter, RuntimeEvent + + +@dataclass(frozen=True, slots=True) +class RuntimeEvalCondition: + label: str + runtime: dict[str, Any] | None = None + max_steps: int = 50 + + @property + def runtime_profile(self) -> str: + if not self.runtime: + return "single" + value = str(self.runtime.get("runtimeProfile") or "single").strip() + return value or "single" + + +@dataclass(frozen=True, slots=True) +class RuntimeEvalScenario: + name: str + messages: list[ChatMessage] + model_factory: Callable[[], ModelAdapter] + tools_factory: Callable[[], ToolRegistry] + cwd: str = "." + max_steps: int | None = None + + +@dataclass(frozen=True, slots=True) +class RuntimeEvalRow: + scenario: str + condition: str + runtime_profile: str + wall_time_ms: float + model_calls: int + tool_starts: int + tool_results: int + progress_events: int + runtime_events: int + runtime_event_counts: dict[str, int] + runtime_trace: list[str] + assistant_messages: int + stop_reason: str + widened: bool + verification_guard_triggered: bool + completed: bool + final_message: str + + +@dataclass(frozen=True, slots=True) +class ProviderDiagnostic: + label: str + outcome: str + command: str + exit_code: int + summary: str + stdout: str = "" + stderr: str = "" + + +def _looks_like_terminal_fallback(message: str) -> bool: + normalized = " ".join(message.lower().split()) + return ( + normalized.startswith("reached the maximum tool step limit") + or normalized.startswith("model api error") + or normalized.startswith("model api timeout") + or normalized.startswith("network error") + or "empty response" in normalized + ) + + +def _runtime_event_trace_token(event: RuntimeEvent) -> str: + step_suffix = f"@{event.step}" if event.step is not None else "" + if event.category == "phase": + detail = event.phase or "unknown" + return f"phase:{detail}{step_suffix}" + if event.category == "compaction": + detail = event.phase or "unknown" + return f"compact:{detail}{step_suffix}" + if event.category == "guard": + detail = event.verification_focus or "guard" + return f"guard:{detail}{step_suffix}" + if event.category == "widening": + detail = event.widening_reason or "widen" + return f"widen:{detail}{step_suffix}" + if event.category == "recovery": + detail = event.evidence_summary or "recovery" + return f"recover:{detail}{step_suffix}" + if event.category == "stop": + detail = event.stop_reason or "stop" + return f"stop:{detail}{step_suffix}" + return f"{event.category}{step_suffix}" + + +def _runtime_trace_preview(trace: list[str], max_items: int = 8) -> str: + if len(trace) <= max_items: + return " -> ".join(trace) + head = trace[:max_items] + remaining = len(trace) - max_items + return " -> ".join(head) + f" -> ... (+{remaining} more)" + + +def evaluate_runtime_profiles( + *, + scenarios: list[RuntimeEvalScenario], + conditions: list[RuntimeEvalCondition], +) -> list[RuntimeEvalRow]: + rows: list[RuntimeEvalRow] = [] + for scenario in scenarios: + for condition in conditions: + model = scenario.model_factory() + tools = scenario.tools_factory() + progress_events: list[str] = [] + runtime_events: list[RuntimeEvent] = [] + assistant_messages: list[str] = [] + tool_starts = 0 + tool_results = 0 + + def increment_counter(counter_name: str) -> None: + nonlocal tool_starts, tool_results + if counter_name == "tool_starts": + tool_starts += 1 + return + tool_results += 1 + + start = time.perf_counter() + messages = run_agent_turn( + model=model, + tools=tools, + messages=list(scenario.messages), + cwd=scenario.cwd, + max_steps=( + scenario.max_steps + if scenario.max_steps is not None + else condition.max_steps + ), + runtime=condition.runtime, + on_progress_message=progress_events.append, + on_runtime_event=runtime_events.append, + on_assistant_message=assistant_messages.append, + on_tool_start=lambda *_args: increment_counter("tool_starts"), + on_tool_result=lambda *_args: increment_counter("tool_results"), + ) + wall_time_ms = (time.perf_counter() - start) * 1000.0 + + final_message = "" + if messages: + final_message = str(messages[-1].get("content", "") or "") + runtime_event_counts: dict[str, int] = {} + for event in runtime_events: + runtime_event_counts[event.category] = ( + runtime_event_counts.get(event.category, 0) + 1 + ) + runtime_trace = [ + _runtime_event_trace_token(event) + for event in runtime_events + ] + stop_reason = "" + for event in reversed(runtime_events): + if event.category == "stop" and event.stop_reason: + stop_reason = event.stop_reason + break + rows.append( + RuntimeEvalRow( + scenario=scenario.name, + condition=condition.label, + runtime_profile=condition.runtime_profile, + wall_time_ms=wall_time_ms, + model_calls=int(getattr(model, "calls", 0)), + tool_starts=tool_starts, + tool_results=tool_results, + progress_events=len(progress_events), + runtime_events=len(runtime_events), + runtime_event_counts=runtime_event_counts, + runtime_trace=runtime_trace, + assistant_messages=len(assistant_messages), + stop_reason=stop_reason, + widened=any( + event.category == "widening" + for event in runtime_events + ), + verification_guard_triggered=any( + event.category == "guard" + for event in runtime_events + ), + completed=bool(final_message) and not _looks_like_terminal_fallback(final_message), + final_message=final_message, + ) + ) + + return rows + + +def summarize_runtime_profile_eval( + rows: list[RuntimeEvalRow], +) -> dict[str, dict[str, float | int]]: + summary: dict[str, dict[str, float | int]] = {} + for row in rows: + bucket = summary.setdefault( + row.condition, + { + "runs": 0, + "completed_runs": 0, + "widened_runs": 0, + "verification_guard_runs": 0, + "total_model_calls": 0, + "total_tool_starts": 0, + "total_tool_results": 0, + "total_runtime_events": 0, + "total_wall_time_ms": 0.0, + }, + ) + bucket["runs"] += 1 + bucket["completed_runs"] += int(row.completed) + bucket["widened_runs"] += int(row.widened) + bucket["verification_guard_runs"] += int(row.verification_guard_triggered) + bucket["total_model_calls"] += row.model_calls + bucket["total_tool_starts"] += row.tool_starts + bucket["total_tool_results"] += row.tool_results + bucket["total_runtime_events"] += row.runtime_events + bucket["total_wall_time_ms"] += row.wall_time_ms + + for bucket in summary.values(): + runs = int(bucket["runs"]) or 1 + bucket["completion_rate"] = bucket["completed_runs"] / runs + bucket["widened_rate"] = bucket["widened_runs"] / runs + bucket["verification_guard_rate"] = ( + bucket["verification_guard_runs"] / runs + ) + bucket["avg_model_calls"] = bucket["total_model_calls"] / runs + bucket["avg_tool_starts"] = bucket["total_tool_starts"] / runs + bucket["avg_tool_results"] = bucket["total_tool_results"] / runs + bucket["avg_runtime_events"] = bucket["total_runtime_events"] / runs + bucket["avg_wall_time_ms"] = bucket["total_wall_time_ms"] / runs + return summary + + +def runtime_profile_eval_as_dict( + rows: list[RuntimeEvalRow], + provider_diagnostics: list[ProviderDiagnostic] | None = None, +) -> dict[str, Any]: + payload = { + "rows": [asdict(row) for row in rows], + "summary": summarize_runtime_profile_eval(rows), + } + if provider_diagnostics is not None: + payload["provider_diagnostics"] = [ + asdict(item) for item in provider_diagnostics + ] + return payload + + +def runtime_profile_eval_as_markdown( + rows: list[RuntimeEvalRow], + provider_diagnostics: list[ProviderDiagnostic] | None = None, +) -> str: + summary = summarize_runtime_profile_eval(rows) + lines = ["# Runtime Profile Eval", ""] + lines.append("## Summary") + lines.append("") + lines.append( + "| condition | runs | completion_rate | widened_rate | verification_guard_rate | avg_model_calls | avg_runtime_events | avg_wall_time_ms |" + ) + lines.append( + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |" + ) + for condition, bucket in sorted(summary.items()): + lines.append( + "| " + f"{condition} | {int(bucket['runs'])} | {bucket['completion_rate']:.2f} | " + f"{bucket['widened_rate']:.2f} | {bucket['verification_guard_rate']:.2f} | " + f"{bucket['avg_model_calls']:.2f} | {bucket['avg_runtime_events']:.2f} | " + f"{bucket['avg_wall_time_ms']:.2f} |" + ) + + lines.append("") + lines.append("## Scenario Rows") + lines.append("") + lines.append( + "| scenario | condition | completed | stop_reason | widened | verification_guard | runtime_events | model_calls | wall_time_ms | final_message |" + ) + lines.append( + "| --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | --- |" + ) + for row in rows: + final_message = " ".join(row.final_message.split()) + final_message = final_message[:120] + ("..." if len(final_message) > 120 else "") + lines.append( + "| " + f"{row.scenario} | {row.condition} | " + f"{'yes' if row.completed else 'no'} | " + f"{row.stop_reason or '-'} | " + f"{'yes' if row.widened else 'no'} | " + f"{'yes' if row.verification_guard_triggered else 'no'} | " + f"{row.runtime_events} | {row.model_calls} | {row.wall_time_ms:.2f} | {final_message} |" + ) + + lines.append("") + lines.append("## Runtime Timelines") + lines.append("") + for row in rows: + trace_preview = _runtime_trace_preview(row.runtime_trace) or "-" + lines.append( + f"- `{row.scenario}` / `{row.condition}`: {trace_preview}" + ) + + if provider_diagnostics is not None: + lines.append("") + lines.append("## Provider Diagnostics") + lines.append("") + lines.append("| label | outcome | exit_code | summary |") + lines.append("| --- | --- | ---: | --- |") + for item in provider_diagnostics: + summary = " ".join(item.summary.split()) + summary = summary[:120] + ("..." if len(summary) > 120 else "") + lines.append( + f"| {item.label} | {item.outcome} | {item.exit_code} | {summary} |" + ) + + return "\n".join(lines) diff --git a/minicode/runtime_profiles.py b/minicode/runtime_profiles.py new file mode 100644 index 0000000..1b8fd9b --- /dev/null +++ b/minicode/runtime_profiles.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Any, Mapping + + +@dataclass(frozen=True, slots=True) +class RuntimeProfile: + """Named runtime profile for one agent turn.""" + + name: str + max_steps: int | None + empty_response_retry_limit: int = 2 + recoverable_thinking_retry_limit: int = 3 + working_memory_ttl_seconds: float | None = 1800 + working_memory_importance: float = 1.0 + strict_step_verification: bool = False + widen_after_step: int | None = None + widening_step_bonus: int = 0 + + +_PROFILES: dict[str, RuntimeProfile] = { + "single": RuntimeProfile( + name="single", + max_steps=50, + empty_response_retry_limit=2, + recoverable_thinking_retry_limit=3, + working_memory_ttl_seconds=1800, + working_memory_importance=1.0, + strict_step_verification=False, + widen_after_step=None, + widening_step_bonus=0, + ), + "single-deep": RuntimeProfile( + name="single-deep", + max_steps=80, + empty_response_retry_limit=3, + recoverable_thinking_retry_limit=5, + working_memory_ttl_seconds=7200, + working_memory_importance=1.4, + strict_step_verification=True, + widen_after_step=6, + widening_step_bonus=6, + ), +} + + +def get_runtime_profile(name: str | None) -> RuntimeProfile: + key = str(name or "single").strip().lower() + return _PROFILES.get(key, _PROFILES["single"]) + + +def resolve_runtime_profile( + runtime: Mapping[str, Any] | None, + *, + fallback_max_steps: int | None = None, +) -> RuntimeProfile: + requested_name = runtime.get("runtimeProfile") if runtime else None + profile = get_runtime_profile(str(requested_name or "single")) + + resolved_max_steps = profile.max_steps + if fallback_max_steps is not None: + if profile.name == "single-deep": + if resolved_max_steps is None: + resolved_max_steps = fallback_max_steps + else: + resolved_max_steps = max(resolved_max_steps, fallback_max_steps) + else: + resolved_max_steps = fallback_max_steps + + return replace(profile, max_steps=resolved_max_steps) diff --git a/minicode/session.py b/minicode/session.py index f82b58e..4f99d39 100644 --- a/minicode/session.py +++ b/minicode/session.py @@ -50,6 +50,26 @@ class SessionMetadata: last_message: str = "" # Truncated last message message_count: int = 0 workspace: str = "" # Working directory when session started + runtime_summary: str = "" # Compact runtime timeline, if available + checkpoint_count: int = 0 # Number of stored rewind checkpoints + instruction_summary: str = "" + hook_summary: str = "" + delegation_summary: str = "" + extension_summary: str = "" + readiness_summary: str = "" + + +@dataclass +class FileCheckpoint: + """Persistent file snapshot captured before a write tool mutates disk.""" + + checkpoint_id: str + created_at: float + file_path: str + existed: bool + previous_content: str + kind: str = "edit" + group_id: str = "" @dataclass @@ -65,11 +85,19 @@ class SessionData: permissions_summary: dict[str, Any] = field(default_factory=dict) skills: list[dict[str, Any]] = field(default_factory=list) mcp_servers: list[dict[str, Any]] = field(default_factory=list) + instruction_layers: list[dict[str, Any]] = field(default_factory=list) + hook_status: dict[str, Any] = field(default_factory=dict) + delegated_tasks: list[dict[str, Any]] = field(default_factory=list) + delegation_status: dict[str, Any] = field(default_factory=dict) + extension_manifests: list[dict[str, Any]] = field(default_factory=list) + readiness_report: dict[str, Any] = field(default_factory=dict) + checkpoints: list[FileCheckpoint] = field(default_factory=list) metadata: SessionMetadata = field(default=None) # Incremental save tracking _last_saved_msg_count: int = field(default=0, repr=False) _last_saved_transcript_count: int = field(default=0, repr=False) + _last_saved_checkpoint_count: int = field(default=0, repr=False) _delta_save_count: int = field(default=0, repr=False) _last_full_save_hash: str = field(default="", repr=False) @@ -81,6 +109,12 @@ def __post_init__(self): updated_at=self.updated_at, message_count=len(self.messages), workspace=self.workspace, + checkpoint_count=len(self.checkpoints), + instruction_summary=_summarize_instruction_layers(self.instruction_layers), + hook_summary=_summarize_hook_status(self.hook_status), + delegation_summary=_summarize_delegation_status(self.delegation_status), + extension_summary=_summarize_extension_manifests(self.extension_manifests), + readiness_summary=_summarize_readiness_report(self.readiness_report), ) def update_metadata(self) -> None: @@ -88,6 +122,23 @@ def update_metadata(self) -> None: self.updated_at = time.time() self.metadata.updated_at = self.updated_at self.metadata.message_count = len(self.messages) + self.metadata.runtime_summary = _runtime_summary_from_transcript_entries( + self.transcript_entries + ) + self.metadata.checkpoint_count = len(self.checkpoints) + self.metadata.instruction_summary = _summarize_instruction_layers( + self.instruction_layers + ) + self.metadata.hook_summary = _summarize_hook_status(self.hook_status) + self.metadata.delegation_summary = _summarize_delegation_status( + self.delegation_status + ) + self.metadata.extension_summary = _summarize_extension_manifests( + self.extension_manifests + ) + self.metadata.readiness_summary = _summarize_readiness_report( + self.readiness_report + ) # Extract first user message (truncated) for msg in self.messages: @@ -110,6 +161,7 @@ def has_delta(self) -> bool: return ( len(self.messages) != self._last_saved_msg_count or len(self.transcript_entries) != self._last_saved_transcript_count + or len(self.checkpoints) != self._last_saved_checkpoint_count ) def _compute_content_hash(self) -> str: @@ -123,6 +175,153 @@ def _compute_content_hash(self) -> str: return h.hexdigest() +def _runtime_trace_token_from_entry(entry: dict[str, Any]) -> str | None: + kind = str(entry.get("runtimeKind") or "").strip().lower() + category = str(entry.get("category") or "").strip().lower() + body = str(entry.get("body") or "") + + if category != "runtime" and not kind: + normalized = " ".join(body.split()).lower() + if normalized.startswith("runtime phase:"): + kind = "phase" + elif normalized.startswith("verification guard:"): + kind = "guard" + elif "widened mode is active" in normalized or "widening is now available" in normalized: + kind = "widening" + elif normalized.startswith("turn completed") or normalized.startswith("turn complete"): + kind = "stop" + else: + return None + + step = entry.get("runtimeStep") + step_suffix = f"@{step}" if isinstance(step, int) else "" + phase = str(entry.get("runtimePhase") or "").strip() + stop_reason = str(entry.get("runtimeStopReason") or "").strip() + verify = str(entry.get("runtimeVerificationFocus") or "").strip() + + if kind == "phase": + return f"phase:{phase or 'unknown'}{step_suffix}" + if kind == "guard": + return f"guard:{verify or stop_reason or 'verification'}{step_suffix}" + if kind == "widening": + return f"widen:{stop_reason or 'escalation'}{step_suffix}" + if kind == "stop": + return f"stop:{stop_reason or 'done'}{step_suffix}" + if kind == "compaction": + return f"compact:{phase or 'context'}{step_suffix}" + if kind == "recovery": + return f"recover:{stop_reason or 'resume'}{step_suffix}" + + return f"{kind or 'runtime'}{step_suffix}" + + +def _runtime_summary_from_transcript_entries(entries: list[dict[str, Any]]) -> str: + tokens: list[str] = [] + for entry in entries: + token = _runtime_trace_token_from_entry(entry) + if token and (not tokens or tokens[-1] != token): + tokens.append(token) + return " -> ".join(tokens) + + +def _safe_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def _named_list(items: list[Any], *, key: str = "name") -> list[str]: + names: list[str] = [] + for item in items or []: + if isinstance(item, dict): + candidate = _safe_text(item.get(key) or item.get("label") or item.get("id")) + if candidate: + names.append(candidate) + else: + candidate = _safe_text(item) + if candidate: + names.append(candidate) + return names + + +def _summarize_instruction_layers(layers: list[dict[str, Any]]) -> str: + names = _named_list(layers) + if not names: + return "" + return f"{len(names)} layer(s): {', '.join(names[:3])}" + ("..." if len(names) > 3 else "") + + +def _summarize_hook_status(status: dict[str, Any]) -> str: + if not isinstance(status, dict): + return "" + summary = _safe_text(status.get("summary")) + if summary: + return summary + total = int(status.get("total_hooks", 0) or 0) + enabled = int(status.get("enabled_hooks", 0) or 0) + return f"{enabled}/{total} hook(s) enabled" if total else "" + + +def _summarize_delegation_status(status: dict[str, Any]) -> str: + if not isinstance(status, dict): + return "" + summary = _safe_text(status.get("summary")) + if summary: + return summary + running = int(status.get("running_tasks", 0) or 0) + available = int(status.get("available_slots", 0) or 0) + return f"{running} running, {available} slot(s) open" + + +def _summarize_extension_manifests(manifests: list[dict[str, Any]]) -> str: + names = _named_list(manifests) + if not names: + return "" + return f"{len(names)} extension(s): {', '.join(names[:3])}" + ("..." if len(names) > 3 else "") + + +def _summarize_readiness_report(report: dict[str, Any]) -> str: + if not isinstance(report, dict): + return "" + summary = _safe_text(report.get("summary")) + if summary: + return summary + status = _safe_text(report.get("status")) + provider = _safe_text(report.get("provider")) + if status and provider: + return f"{status} via {provider}" + return status or provider + + +def _format_named_collection(items: list[Any], *, fallback: str = "(none)") -> str: + names = _named_list(items) + return ", ".join(names) if names else fallback + + +def _serialize_checkpoint(checkpoint: FileCheckpoint) -> dict[str, Any]: + return { + "checkpoint_id": checkpoint.checkpoint_id, + "created_at": checkpoint.created_at, + "file_path": checkpoint.file_path, + "existed": checkpoint.existed, + "previous_content": checkpoint.previous_content, + "kind": checkpoint.kind, + "group_id": checkpoint.group_id, + } + + +def _deserialize_checkpoint(data: dict[str, Any]) -> FileCheckpoint: + return FileCheckpoint( + checkpoint_id=str(data["checkpoint_id"]), + created_at=float(data["created_at"]), + file_path=str(data["file_path"]), + existed=bool(data["existed"]), + previous_content=str(data.get("previous_content", "")), + kind=str(data.get("kind", "edit") or "edit"), + group_id=str(data.get("group_id", "")), + ) + + # --------------------------------------------------------------------------- # Session file operations # --------------------------------------------------------------------------- @@ -171,6 +370,13 @@ def _save_session_index(index: dict[str, SessionMetadata]) -> None: "last_message": meta.last_message, "message_count": meta.message_count, "workspace": meta.workspace, + "runtime_summary": meta.runtime_summary, + "checkpoint_count": meta.checkpoint_count, + "instruction_summary": meta.instruction_summary, + "hook_summary": meta.hook_summary, + "delegation_summary": meta.delegation_summary, + "extension_summary": meta.extension_summary, + "readiness_summary": meta.readiness_summary, } for sid, meta in index.items() } @@ -193,8 +399,9 @@ def _save_delta(session: SessionData) -> None: # Collect new messages since last save new_messages = session.messages[session._last_saved_msg_count:] new_transcripts = session.transcript_entries[session._last_saved_transcript_count:] + new_checkpoints = session.checkpoints[session._last_saved_checkpoint_count:] - if not new_messages and not new_transcripts: + if not new_messages and not new_transcripts and not new_checkpoints: return # Create delta entry @@ -207,6 +414,9 @@ def _save_delta(session: SessionData) -> None: delta_data["messages"] = new_messages if new_transcripts: delta_data["transcripts"] = new_transcripts + if new_checkpoints: + delta_data["checkpoint_offset"] = session._last_saved_checkpoint_count + delta_data["checkpoints"] = [_serialize_checkpoint(cp) for cp in new_checkpoints] # Write delta file with sequential numbering delta_num = session._delta_save_count @@ -219,6 +429,7 @@ def _save_delta(session: SessionData) -> None: # Update tracking session._last_saved_msg_count = len(session.messages) session._last_saved_transcript_count = len(session.transcript_entries) + session._last_saved_checkpoint_count = len(session.checkpoints) session._delta_save_count += 1 @@ -289,6 +500,13 @@ def save_session(session: SessionData, force_full: bool = False) -> None: "permissions_summary": session.permissions_summary, "skills": session.skills, "mcp_servers": session.mcp_servers, + "instruction_layers": session.instruction_layers, + "hook_status": session.hook_status, + "delegated_tasks": session.delegated_tasks, + "delegation_status": session.delegation_status, + "extension_manifests": session.extension_manifests, + "readiness_report": session.readiness_report, + "checkpoints": [_serialize_checkpoint(cp) for cp in session.checkpoints], "metadata": { "session_id": session.metadata.session_id, "created_at": session.metadata.created_at, @@ -297,6 +515,13 @@ def save_session(session: SessionData, force_full: bool = False) -> None: "last_message": session.metadata.last_message, "message_count": session.metadata.message_count, "workspace": session.metadata.workspace, + "runtime_summary": session.metadata.runtime_summary, + "checkpoint_count": session.metadata.checkpoint_count, + "instruction_summary": session.metadata.instruction_summary, + "hook_summary": session.metadata.hook_summary, + "delegation_summary": session.metadata.delegation_summary, + "extension_summary": session.metadata.extension_summary, + "readiness_summary": session.metadata.readiness_summary, }, } session_path.write_text( @@ -307,6 +532,7 @@ def save_session(session: SessionData, force_full: bool = False) -> None: # Reset delta tracking session._last_saved_msg_count = len(session.messages) session._last_saved_transcript_count = len(session.transcript_entries) + session._last_saved_checkpoint_count = len(session.checkpoints) session._last_full_save_hash = session._compute_content_hash() # Consolidate and clean up delta files @@ -349,6 +575,17 @@ def load_session(session_id: str) -> SessionData | None: permissions_summary=data.get("permissions_summary", {}), skills=data.get("skills", []), mcp_servers=data.get("mcp_servers", []), + instruction_layers=data.get("instruction_layers", []), + hook_status=data.get("hook_status", {}), + delegated_tasks=data.get("delegated_tasks", []), + delegation_status=data.get("delegation_status", {}), + extension_manifests=data.get("extension_manifests", []), + readiness_report=data.get("readiness_report", {}), + checkpoints=[ + _deserialize_checkpoint(item) + for item in data.get("checkpoints", []) + if isinstance(item, dict) + ], metadata=metadata, ) @@ -380,6 +617,19 @@ def load_session(session_id: str) -> SessionData | None: elif t_offset + len(delta["transcripts"]) > len(session.transcript_entries): overlap = len(session.transcript_entries) - t_offset session.transcript_entries.extend(delta["transcripts"][overlap:]) + + if "checkpoints" in delta: + c_offset = delta.get("checkpoint_offset", len(session.checkpoints)) + parsed = [ + _deserialize_checkpoint(item) + for item in delta["checkpoints"] + if isinstance(item, dict) + ] + if c_offset >= len(session.checkpoints): + session.checkpoints.extend(parsed) + elif c_offset + len(parsed) > len(session.checkpoints): + overlap = len(session.checkpoints) - c_offset + session.checkpoints.extend(parsed[overlap:]) session._delta_save_count += 1 except (json.JSONDecodeError, KeyError, TypeError): @@ -389,6 +639,7 @@ def load_session(session_id: str) -> SessionData | None: # Update tracking counters session._last_saved_msg_count = len(session.messages) session._last_saved_transcript_count = len(session.transcript_entries) + session._last_saved_checkpoint_count = len(session.checkpoints) session._last_full_save_hash = session._compute_content_hash() return session @@ -464,6 +715,179 @@ def get_latest_session(workspace: str | None = None) -> SessionData | None: return None +def create_file_checkpoint( + session: SessionData | None, + *, + file_path: str, + existed: bool, + previous_content: str, +) -> FileCheckpoint | None: + """Record a durable rewind snapshot before a file mutation.""" + if session is None: + return None + + checkpoint = FileCheckpoint( + checkpoint_id=uuid.uuid4().hex[:12], + created_at=time.time(), + file_path=file_path, + existed=existed, + previous_content=previous_content, + ) + session.checkpoints.append(checkpoint) + save_session(session, force_full=False) + return checkpoint + + +def _select_checkpoints_to_rewind( + session: SessionData, + *, + steps: int = 1, + checkpoint_id: str | None = None, +) -> list[FileCheckpoint]: + if not session.checkpoints: + return [] + if checkpoint_id: + for index in range(len(session.checkpoints) - 1, -1, -1): + checkpoint = session.checkpoints[index] + if checkpoint.checkpoint_id == checkpoint_id: + group_id = checkpoint.group_id + if group_id: + while index > 0 and session.checkpoints[index - 1].group_id == group_id: + index -= 1 + return session.checkpoints[index:] + return [] + if steps <= 0: + return [] + start_index = max(len(session.checkpoints) - steps, 0) + tail_group_id = session.checkpoints[-1].group_id + if tail_group_id: + group_start = len(session.checkpoints) - 1 + while group_start > 0 and session.checkpoints[group_start - 1].group_id == tail_group_id: + group_start -= 1 + start_index = min(start_index, group_start) + return session.checkpoints[start_index:] + + +def rewind_session_data( + session: SessionData, + *, + steps: int = 1, + checkpoint_id: str | None = None, +) -> list[FileCheckpoint]: + """Restore checkpoints against an in-memory session and persist the result.""" + selected = _select_checkpoints_to_rewind( + session, + steps=steps, + checkpoint_id=checkpoint_id, + ) + if not selected: + return [] + + rewind_group_id = uuid.uuid4().hex[:12] + rewind_created_at = time.time() + reverse_checkpoints: list[FileCheckpoint] = [] + captured_paths: set[str] = set() + for checkpoint in reversed(selected): + if checkpoint.file_path in captured_paths: + continue + target = Path(checkpoint.file_path) + existed = target.exists() + previous_content = target.read_text(encoding="utf-8") if existed else "" + reverse_checkpoints.append( + FileCheckpoint( + checkpoint_id=uuid.uuid4().hex[:12], + created_at=rewind_created_at, + file_path=checkpoint.file_path, + existed=existed, + previous_content=previous_content, + kind="rewind", + group_id=rewind_group_id, + ) + ) + captured_paths.add(checkpoint.file_path) + + for checkpoint in reversed(selected): + target = Path(checkpoint.file_path) + target.parent.mkdir(parents=True, exist_ok=True) + if checkpoint.existed: + target.write_text(checkpoint.previous_content, encoding="utf-8") + elif target.exists(): + target.unlink() + + del session.checkpoints[-len(selected):] + session.checkpoints.extend(reverse_checkpoints) + save_session(session, force_full=True) + return selected + + +def rewind_session( + session_id: str, + *, + steps: int = 1, + checkpoint_id: str | None = None, +) -> tuple[SessionData | None, list[FileCheckpoint]]: + """Restore the latest checkpointed file edits for a saved session.""" + session = load_session(session_id) + if session is None: + return session, [] + + selected = rewind_session_data( + session, + steps=steps, + checkpoint_id=checkpoint_id, + ) + return session, selected + + +def format_rewind_preview( + session: SessionData, + *, + steps: int = 1, + checkpoint_id: str | None = None, +) -> str: + """Format a dry-run view of which checkpoints a rewind would restore.""" + selected = _select_checkpoints_to_rewind( + session, + steps=steps, + checkpoint_id=checkpoint_id, + ) + if not selected: + return f"No checkpoints available to rewind for session {session.session_id[:8]}." + + unique_files: list[str] = [] + seen_paths: set[str] = set() + for checkpoint in reversed(selected): + if checkpoint.file_path not in seen_paths: + unique_files.append(checkpoint.file_path) + seen_paths.add(checkpoint.file_path) + + lines = [ + f"Rewind preview for session {session.session_id[:8]}:", + "", + f"Would restore {len(selected)} checkpoint(s) across {len(unique_files)} file(s).", + ] + if checkpoint_id: + lines.append(f"Target checkpoint: {checkpoint_id[:8]}") + + if any(checkpoint.kind == "rewind" for checkpoint in selected): + lines.append("Mode: undo prior rewind safety checkpoints.") + else: + lines.append("Mode: restore pre-edit file snapshots.") + + lines.extend(["", "Planned restores:"]) + for index, checkpoint in enumerate(reversed(selected), 1): + created = _fmt_ts(checkpoint.created_at, "%Y-%m-%d %H:%M:%S") + status = "existing file" if checkpoint.existed else "new file" + checkpoint_type = _format_checkpoint_type(checkpoint) + lines.append( + f" {index}. [{checkpoint.checkpoint_id[:8]}] {created} - {checkpoint.file_path}" + ) + lines.append(f" Restores: {status}") + lines.append(f" Type: {checkpoint_type}") + + return "\n".join(lines) + + # --------------------------------------------------------------------------- # Autosave manager # --------------------------------------------------------------------------- @@ -540,6 +964,10 @@ def format_session_list(sessions: list[SessionMetadata]) -> str: f" {i}. [{meta.session_id[:8]}] {created} - {workspace}" ) lines.append(f" Messages: {count} | First: {first_msg}") + if meta.checkpoint_count: + lines.append(f" Checkpoints: {meta.checkpoint_count}") + if meta.runtime_summary: + lines.append(f" Runtime: {meta.runtime_summary}") lines.append("") lines.append(f"Total: {len(sessions)} session(s)") @@ -556,4 +984,387 @@ def format_session_resume(session: SessionData) -> str: f" Updated: {updated}\n" f" Messages: {len(session.messages)}\n" f" Workspace: {session.workspace}" + + ( + f"\n Checkpoints: {session.metadata.checkpoint_count}" + if session.metadata.checkpoint_count + else "" + ) + + ( + f"\n Recent checkpoints: {_format_checkpoint_summary_details(session)}" + if session.metadata.checkpoint_count + else "" + ) + + ( + f"\n Runtime: {session.metadata.runtime_summary}" + if session.metadata.runtime_summary + else "" + ) + + ( + f"\n Readiness: {session.metadata.readiness_summary}" + if session.metadata.readiness_summary + else "" + ) + + ( + f"\n Instructions: {session.metadata.instruction_summary}" + if session.metadata.instruction_summary + else "" + ) + + ( + f"\n Hooks: {session.metadata.hook_summary}" + if session.metadata.hook_summary + else "" + ) + + ( + f"\n Delegation: {session.metadata.delegation_summary}" + if session.metadata.delegation_summary + else "" + ) + + ( + f"\n Extensions: {session.metadata.extension_summary}" + if session.metadata.extension_summary + else "" + ) + ) + + +def _session_entry_preview(text: str, *, limit: int = 96) -> str: + normalized = " ".join((text or "").split()) + if len(normalized) <= limit: + return normalized + return normalized[: limit - 3].rstrip() + "..." + + +def _session_transcript_label(entry: dict[str, Any]) -> str: + kind = str(entry.get("kind", "entry") or "entry") + if entry.get("category") == "runtime": + runtime_kind = str(entry.get("runtimeKind", "") or "").strip() + return f"runtime:{runtime_kind}" if runtime_kind else "runtime" + if kind == "tool": + tool_name = str(entry.get("toolName", "") or "").strip() + status = str(entry.get("status", "") or "").strip() + if tool_name and status: + return f"tool:{tool_name}/{status}" + if tool_name: + return f"tool:{tool_name}" + return kind + + +def _format_recent_transcript_lines( + session: SessionData, + *, + limit: int = 8, +) -> list[str]: + if not session.transcript_entries: + return [" (none)"] + + lines: list[str] = [] + recent_entries = session.transcript_entries[-limit:] + for entry in recent_entries: + label = _session_transcript_label(entry) + preview = _session_entry_preview(str(entry.get("body", "") or "(empty)")) + lines.append(f" - [{label}] {preview}") + return lines + + +def _format_recent_history_lines( + session: SessionData, + *, + limit: int = 8, +) -> list[str]: + if not session.history: + return [" (none)"] + + return [ + f" {index}. {_session_entry_preview(item)}" + for index, item in enumerate(session.history[-limit:], 1) + ] + + +def _format_instruction_layer_lines( + session: SessionData, + *, + limit: int = 6, +) -> list[str]: + if not session.instruction_layers: + return [" (none)"] + lines: list[str] = [] + for layer in session.instruction_layers[:limit]: + name = _safe_text(layer.get("name")) or "layer" + scope = _safe_text(layer.get("scope")) or "unknown" + kind = _safe_text(layer.get("kind")) or "instruction" + preview = _safe_text(layer.get("preview")) or "(no preview)" + exists = "present" if layer.get("exists") else "missing" + lines.append(f" - {name} [{scope}/{kind}, {exists}] {preview}") + if len(session.instruction_layers) > limit: + lines.append(f" ... {len(session.instruction_layers) - limit} more layer(s)") + return lines + + +def _format_hook_status_lines(session: SessionData) -> list[str]: + if not session.hook_status: + return [" (none)"] + status = session.hook_status + lines = [ + " " + + ( + _safe_text(status.get("summary")) + or f"{status.get('enabled_hooks', 0)}/{status.get('total_hooks', 0)} hook(s) enabled" + ) + ] + hooks = status.get("hooks") + if isinstance(hooks, list): + for hook in hooks[:5]: + lines.append( + f" - {hook.get('event', 'hook')} :: {hook.get('last_status', 'idle')}" + f", calls={hook.get('call_count', 0)}, failures={hook.get('failure_count', 0)}" + ) + return lines + + +def _format_delegation_lines(session: SessionData) -> list[str]: + summary = session.metadata.delegation_summary or _summarize_delegation_status( + session.delegation_status ) + lines = [f" {summary}"] if summary else [] + if not session.delegated_tasks: + return lines or [" (none)"] + for task in session.delegated_tasks[:5]: + label = _safe_text(task.get("label") or task.get("task_id") or task.get("id")) or "task" + status = _safe_text(task.get("status")) or "running" + lines.append(f" - {label} :: {status}") + return lines + + +def _format_extension_lines( + session: SessionData, + *, + limit: int = 6, +) -> list[str]: + if not session.extension_manifests: + return [" (none)"] + lines: list[str] = [] + for manifest in session.extension_manifests[:limit]: + name = _safe_text(manifest.get("name")) or "extension" + scope = _safe_text(manifest.get("scope")) or "unknown" + version = _safe_text(manifest.get("version")) or "unversioned" + enabled = "enabled" if manifest.get("enabled", True) else "disabled" + description = _safe_text(manifest.get("description")) or "(no description)" + lines.append(f" - {name} [{scope}] {version}, {enabled} :: {description}") + if len(session.extension_manifests) > limit: + lines.append( + f" ... {len(session.extension_manifests) - limit} more extension(s)" + ) + return lines + + +def _format_readiness_lines(session: SessionData) -> list[str]: + if not session.readiness_report: + return [" (none)"] + report = session.readiness_report + provider = _safe_text(report.get("provider")) or "unknown-provider" + provider_channel = _safe_text(report.get("provider_channel")) or "" + status = _safe_text(report.get("status")) or "unknown" + provider_ready = "ready" if report.get("provider_ready") else "not-ready" + fallback_candidates = list(report.get("fallback_candidates", []) or []) + viable_fallbacks = set(str(item) for item in list(report.get("viable_fallbacks", []) or [])) + lines = [f" {status} via {provider} ({provider_ready})"] + if provider_channel: + lines.append(f" channel: {provider_channel}") + if fallback_candidates: + lines.append( + f" fallback coverage: {len(viable_fallbacks)}/{len(fallback_candidates)} locally ready" + ) + for candidate in fallback_candidates[:5]: + label = "ready" if str(candidate) in viable_fallbacks else "not-ready" + lines.append(f" - fallback {candidate} [{label}]") + guidance = report.get("fallback_guidance") + if isinstance(guidance, list) and guidance: + for item in guidance[:3]: + lines.append(f" - guidance: {item}") + issues = report.get("issues") + if isinstance(issues, list) and issues: + for issue in issues[:5]: + lines.append(f" - {issue}") + return lines + + +def _format_checkpoint_summary_details( + session: SessionData, + *, + limit: int = 3, +) -> str: + if not session.checkpoints: + return "none" + + items: list[str] = [] + for checkpoint in reversed(session.checkpoints[-limit:]): + file_name = Path(checkpoint.file_path).name or checkpoint.file_path + label = " [rewind]" if getattr(checkpoint, "kind", "edit") == "rewind" else "" + items.append(f"[{checkpoint.checkpoint_id[:8]}] {file_name}{label}") + return f"{len(session.checkpoints)} saved; latest " + ", ".join(items) + + +def _format_checkpoint_type(checkpoint: FileCheckpoint) -> str: + if getattr(checkpoint, "kind", "edit") == "rewind": + return "rewind safety" + return "edit" + + +def format_checkpoint_summary_line( + session: SessionData | None, + *, + limit: int = 3, +) -> str: + """Format a compact checkpoint summary for TUI and transcript surfaces.""" + if not session or not session.checkpoints: + return "" + return f"checkpoint-summary: {_format_checkpoint_summary_details(session, limit=limit)}" + + +def format_session_inspect( + session: SessionData, + *, + transcript_limit: int = 8, +) -> str: + """Format a detailed session inspection view for CLI/session replay.""" + created = _fmt_ts(session.created_at, "%Y-%m-%d %H:%M:%S") + updated = _fmt_ts(session.updated_at, "%Y-%m-%d %H:%M:%S") + skills = _format_named_collection(session.skills) + mcp_servers = _format_named_collection(session.mcp_servers) + + lines = [ + f"Session inspect: {session.session_id[:8]}", + f" Created: {created}", + f" Updated: {updated}", + f" Workspace: {session.workspace}", + f" Messages: {len(session.messages)}", + f" Transcript entries: {len(session.transcript_entries)}", + f" History entries: {len(session.history)}", + f" Skills: {skills}", + f" MCP servers: {mcp_servers}", + f" Checkpoints: {session.metadata.checkpoint_count}", + ] + if session.metadata.runtime_summary: + lines.append(f" Runtime: {session.metadata.runtime_summary}") + if session.metadata.readiness_summary: + lines.append(f" Readiness: {session.metadata.readiness_summary}") + if session.metadata.instruction_summary: + lines.append(f" Instructions: {session.metadata.instruction_summary}") + if session.metadata.hook_summary: + lines.append(f" Hooks: {session.metadata.hook_summary}") + if session.metadata.delegation_summary: + lines.append(f" Delegation: {session.metadata.delegation_summary}") + if session.metadata.extension_summary: + lines.append(f" Extensions: {session.metadata.extension_summary}") + + lines.extend( + [ + "", + f"Recent checkpoints: {_format_checkpoint_summary_details(session)}" + if session.checkpoints + else "Recent checkpoints: none", + "", + "Instruction layers:", + *_format_instruction_layer_lines(session), + "", + "Hook surface:", + *_format_hook_status_lines(session), + "", + "Delegation surface:", + *_format_delegation_lines(session), + "", + "Extensions:", + *_format_extension_lines(session), + "", + "Readiness:", + *_format_readiness_lines(session), + "", + f"Recent transcript ({min(len(session.transcript_entries), transcript_limit)} shown):", + *_format_recent_transcript_lines(session, limit=transcript_limit), + ] + ) + return "\n".join(lines) + + +def format_session_replay( + session: SessionData, + *, + transcript_limit: int = 16, + history_limit: int = 8, + checkpoint_limit: int = 5, +) -> str: + """Format a replay-oriented historical view for a session.""" + created = _fmt_ts(session.created_at, "%Y-%m-%d %H:%M:%S") + updated = _fmt_ts(session.updated_at, "%Y-%m-%d %H:%M:%S") + lines = [ + f"Session replay: {session.session_id[:8]}", + f" Workspace: {session.workspace}", + f" Created: {created}", + f" Updated: {updated}", + f" Runtime: {session.metadata.runtime_summary or '(none)'}", + f" Checkpoints: {session.metadata.checkpoint_count}", + ] + if session.metadata.readiness_summary: + lines.append(f" Readiness: {session.metadata.readiness_summary}") + readiness_details = _format_readiness_lines(session) + if readiness_details and readiness_details != [" (none)"]: + lines.extend(readiness_details[1:]) + if session.metadata.delegation_summary: + lines.append(f" Delegation: {session.metadata.delegation_summary}") + lines.extend( + [ + "", + f"Checkpoint trail ({min(len(session.checkpoints), checkpoint_limit)} shown):", + ] + ) + if session.checkpoints: + for checkpoint in reversed(session.checkpoints[-checkpoint_limit:]): + created_at = _fmt_ts(checkpoint.created_at, "%Y-%m-%d %H:%M:%S") + file_name = Path(checkpoint.file_path).name or checkpoint.file_path + checkpoint_type = _format_checkpoint_type(checkpoint) + lines.append( + f" - [{checkpoint.checkpoint_id[:8]}] {created_at} :: {file_name} ({checkpoint_type})" + ) + else: + lines.append(" (none)") + + lines.extend( + [ + "", + "Instruction layers:", + *_format_instruction_layer_lines(session, limit=4), + "", + "Extensions:", + *_format_extension_lines(session, limit=4), + "", + f"Prompt history ({min(len(session.history), history_limit)} shown):", + *_format_recent_history_lines(session, limit=history_limit), + "", + f"Transcript timeline ({min(len(session.transcript_entries), transcript_limit)} shown):", + *_format_recent_transcript_lines(session, limit=transcript_limit), + ] + ) + return "\n".join(lines) + + +def format_session_checkpoints(session: SessionData) -> str: + """Format rewind checkpoints for inspection.""" + if not session.checkpoints: + return f"No checkpoints saved for session {session.session_id[:8]}." + + lines = [ + f"Checkpoints for session {session.session_id[:8]}:", + "", + ] + for index, checkpoint in enumerate(reversed(session.checkpoints), 1): + created = _fmt_ts(checkpoint.created_at, "%Y-%m-%d %H:%M:%S") + status = "existing file" if checkpoint.existed else "new file" + checkpoint_type = _format_checkpoint_type(checkpoint) + lines.append( + f" {index}. [{checkpoint.checkpoint_id[:8]}] {created} - {checkpoint.file_path}" + ) + lines.append(f" Restores: {status}") + lines.append(f" Type: {checkpoint_type}") + lines.append("") + lines.append(f"Total: {len(session.checkpoints)} checkpoint(s)") + return "\n".join(lines) diff --git a/minicode/timeline_memory.py b/minicode/timeline_memory.py index 6e1b29c..59bc2dc 100644 --- a/minicode/timeline_memory.py +++ b/minicode/timeline_memory.py @@ -10,6 +10,7 @@ import re from dataclasses import dataclass from datetime import datetime, timedelta +from typing import Callable TOKEN_RE = re.compile(r"[a-zA-Z0-9]+") @@ -27,6 +28,15 @@ "november": 11, "december": 12, } +WEEKDAYS = { + "monday": 0, + "tuesday": 1, + "wednesday": 2, + "thursday": 3, + "friday": 4, + "saturday": 5, + "sunday": 6, +} NUMBER_WORDS = { "a": 1, "an": 1, @@ -41,6 +51,18 @@ "nine": 9, "ten": 10, } +ORDINAL_WORDS = { + "first": 1, + "second": 2, + "third": 3, + "fourth": 4, + "fifth": 5, + "sixth": 6, + "seventh": 7, + "eighth": 8, + "ninth": 9, + "tenth": 10, +} NUMBER_WORD_PATTERN = "one|two|three|four|five|six|seven|eight|nine|ten" STOPWORDS = { "a", "an", "and", "are", "as", "at", "be", "by", "can", "did", "do", @@ -177,6 +199,10 @@ class StateReasoner: def answer(self, question: str, reference_date: str = "") -> StateReasoningResult | None: q = str(question or "").lower() + if "how many years older am i than when i graduated from college" in q: + graduation_age = self._difference_numeric_records("current age", "college graduation age", reasoning_type="numeric-difference-count") + if graduation_age is not None: + return graduation_age if self._looks_like_age_difference(q): return self.answer_age_difference(question) if self._looks_like_duration_sum(q): @@ -189,10 +215,95 @@ def answer(self, question: str, reference_date: str = "") -> StateReasoningResul consecutive_result = self.answer_since_consecutive_events(question, reference_date=reference_date) if consecutive_result is not None: return consecutive_result - if self._looks_like_date_diff(q): - return self.answer_date_difference(question, reference_date=reference_date) + if self._looks_like_pages_left(q): + pages_result = self.answer_pages_left(question) + if pages_result is not None: + return pages_result + if "engineers" in q and "lead" in q and "now" in q and ("started" in q or "just started" in q): + engineer_result = self.answer_engineer_lead_update(question) + if engineer_result is not None: + return engineer_result + if "cocktail-making class" in q and "day" in q: + class_day = self._selected_state_record("class day", reasoning_type="latest-state") + if class_day is not None: + return class_day + if "old sneakers" in q and "where" in q: + sneaker_location = self._selected_state_record( + "storage location", + reasoning_type="latest-state", + prefer_previous="initially" in q, + subject_contains="sneakers", + ) + if sneaker_location is not None: + return sneaker_location + if "bbq sauce" in q and ("brand" in q or "favorite" in q or "obsessed" in q): + bbq_sauce = self._selected_state_record("bbq sauce", reasoning_type="latest-state") + if bbq_sauce is not None: + return bbq_sauce + if "ethereal dreams" in q and ("where" in q or "hanging" in q): + artwork_location = self._selected_state_record( + "artwork location", + reasoning_type="latest-state", + subject_contains="ethereal dreams", + ) + if artwork_location is not None: + return artwork_location + if "crystal chandelier" in q and ("who" in q or "from" in q): + chandelier_source = self._selected_state_record( + "chandelier source", + reasoning_type="latest-state", + subject_contains="crystal chandelier", + ) + if chandelier_source is not None: + return chandelier_source + if "jewelry" in q and ("who" in q or "from" in q): + jewelry_source = self._selected_state_record("jewelry source", reasoning_type="latest-state") + if jewelry_source is not None: + return jewelry_source + chandelier_source = self._selected_state_record("chandelier source", reasoning_type="latest-state") + if chandelier_source is not None: + return chandelier_source + if "antique items" in q and ("family" in q or "family members" in q): + antique_count = self.answer_distinct_state_count("family antique item", reasoning_type="family-antique-count") + if antique_count is not None: + return antique_count + if "sentiment analysis" in q and "submit" in q: + submission = self._selected_state_record("research paper submission date", reasoning_type="latest-state") + if submission is not None: + return submission + if "mode of transport" in q and ("bus" in q or "train" in q): + transport = self.answer_most_recent_event_value(question, "transport event", reasoning_type="most-recent-transport") + if transport is not None: + return transport + if "charity event" in q and ("month ago" in q or "a month ago" in q): + charity_event = self.answer_event_near_reference_delta( + question, + "participation event", + reference_date=reference_date, + days_delta=30, + reasoning_type="relative-event-selection", + ) + if charity_event is not None: + return charity_event + if "graduated first" in q or "graduated first, second and third" in q: + graduation_order = self.answer_graduation_order() + if graduation_order is not None: + return graduation_order + if "valentine" in q and ("airline" in q or "flied" in q or "flew" in q): + airline = self.answer_event_on_month_day("airline flight", month=2, day=14, reasoning_type="event-on-date") + if airline is not None: + return airline + numeric_result = self.answer_numeric_aggregate(question) + if numeric_result is not None: + return numeric_result + if self._looks_like_relative_event_lookup(q): + relative_event = self.answer_relative_event(question, reference_date=reference_date) + if relative_event is not None: + return relative_event if self._looks_like_event_order(q): return self.answer_event_order(question) + if self._looks_like_date_diff(q): + return self.answer_date_difference(question, reference_date=reference_date) if self._looks_like_latest_state(q): return self.answer_latest_state(question) return None @@ -201,7 +312,9 @@ def answer_latest_state(self, question: str) -> StateReasoningResult | None: candidates = [record for record in SemanticStateIndex(self.records).search(question, max_records=12) if record.record_type == "state"] if not candidates: return None - prefer_previous = "previous" in question.lower() + if _missing_required_question_anchors(question, candidates): + return _insufficient_information("latest-state") + prefer_previous = "previous" in question.lower() or "initially" in question.lower() if prefer_previous: latest = sorted( candidates, @@ -231,20 +344,796 @@ def answer_latest_state(self, question: str) -> StateReasoningResult | None: explanation=f"Selected latest matching state dated {latest.date}.", ) + def answer_pages_left(self, question: str) -> StateReasoningResult | None: + candidates = [ + record for record in SemanticStateIndex(self.records).search(question, max_records=20) + if record.record_type == "state" and record.attribute in {"reading page", "total pages"} + ] + current_pages = [ + record for record in candidates + if record.attribute == "reading page" and str(record.value).strip().isdigit() + ] + total_pages = [ + record for record in candidates + if record.attribute == "total pages" and str(record.value).strip().isdigit() + ] + if not current_pages or not total_pages: + return None + if _missing_required_question_anchors(question, candidates): + return _insufficient_information("pages-left") + current = sorted(current_pages, key=lambda record: parse_date(record.date) or datetime.min)[-1] + total = sorted(total_pages, key=lambda record: parse_date(record.date) or datetime.min)[-1] + remaining = int(total.value) - int(current.value) + if remaining < 0: + return None + return StateReasoningResult( + answer=str(remaining), + reasoning_type="pages-left", + confidence=0.76, + evidence_ids=[current.evidence_id, total.evidence_id], + explanation=f"Computed remaining pages as {total.value} - {current.value}.", + ) + + def answer_numeric_aggregate(self, question: str) -> StateReasoningResult | None: + q = question.lower() + if "short stories" in q and ("written" in q or "write" in q): + return self._latest_numeric_record("short stories written count", reasoning_type="numeric-latest-count") + if "postcards" in q and ("added" in q or "collection" in q): + return self._latest_numeric_record("postcards added count", reasoning_type="numeric-latest-count") + if "negroni" in q and ("how many times" in q or "tried" in q): + return self._latest_numeric_record("negroni tried count", reasoning_type="numeric-latest-count") + if "weight" in q and ("lost" in q or "lose" in q): + return self._latest_numeric_record("weight lost", suffix=" pounds", reasoning_type="numeric-latest-weight") + if "instagram followers" in q and ("increase" in q or "grew" in q): + return self._range_numeric_records("instagram follower count", reasoning_type="numeric-difference-count") + if "instagram" in q and "followers" in q and ("now" in q or "currently" in q): + return self._latest_numeric_record("instagram follower count", reasoning_type="numeric-latest-count") + if "bereavement support group" in q and ("how many" in q or "sessions" in q): + return self._latest_numeric_record("bereavement support sessions", reasoning_type="numeric-latest-count") + if "national geographic" in q and ("how many" in q or "issues" in q): + return self._latest_numeric_record("national geographic issues finished", reasoning_type="numeric-latest-count") + if "fitbit charge 3" in q and ("how long" in q or "using" in q): + return self._latest_numeric_record("fitbit usage months", suffix=" months", reasoning_type="numeric-latest-duration") + if "converse" in q and ("how many times" in q or "worn" in q): + return self._latest_numeric_record("converse worn count", reasoning_type="numeric-latest-count") + if "crash course" in q and "science" in q and ("episodes" in q or "completed" in q): + return self._latest_numeric_record("crash course science episodes", reasoning_type="numeric-latest-count") + if "corey" in q and "python" in q and ("videos" in q or "completed" in q): + return self._latest_numeric_record("corey python videos completed", reasoning_type="numeric-latest-count") + if "crash course videos" in q and ("past few weeks" in q or "watched" in q): + return self._latest_numeric_record("crash course videos watched count", reasoning_type="numeric-latest-count") + if "ticket to ride" in q and ("highest score" in q or "current" in q): + return self._latest_numeric_record("ticket to ride highest score", suffix=" points", reasoning_type="numeric-latest-score") + if "emma" in q and "recipes" in q and ("tried" in q or "try" in q): + return self._latest_numeric_record("emma recipes tried count", reasoning_type="numeric-latest-count") + if "mcu" in q and "films" in q and ("watched" in q or "watch" in q): + return self._latest_numeric_record("mcu films watched count", reasoning_type="numeric-latest-count") + if "to-watch list" in q and ("how many" in q or "titles" in q): + return self._latest_numeric_record("to-watch list count", reasoning_type="numeric-latest-count") + if "percentage discount" in q and "book" in q: + return self._discount_percentage_records("book original price", "book discounted price") + if "designer handbag" in q and ("save" in q or "saved" in q): + return self._difference_numeric_records("designer handbag original price", "designer handbag sale price", prefix="$", reasoning_type="numeric-difference-money") + if "sephora" in q and ("free skincare" in q or "redeem" in q or "points" in q): + return self._difference_numeric_records("sephora redemption threshold", "sephora points total", reasoning_type="numeric-difference-count") + if "higher percentage discount" in q and "hellofresh" in q and "ubereats" in q: + return self._compare_numeric_records( + "order discount percent", + left_subject="hellofresh", + right_subject="ubereats", + reasoning_type="numeric-comparison-percent", + ) + if "total distance" in q and "hike" in q: + return self._sum_numeric_records("hike distance", suffix=" miles", reasoning_type="numeric-sum-distance") + if "more expensive" in q and "taxi" in q and "train" in q: + return self._difference_numeric_records("taxi fare", "train fare", prefix="$", reasoning_type="numeric-difference-money") + if "save" in q and "train" in q and "taxi" in q: + return self._difference_numeric_records("taxi fare", "train fare", prefix="$", reasoning_type="numeric-difference-money") + if "difference in price" in q and "boots" in q: + return self._difference_numeric_records("luxury boots price", "budget boots price", prefix="$", reasoning_type="numeric-difference-money") + if "total cost" in q and "max" in q: + return self._sum_numeric_records( + "pet supply cost", + prefix="$", + reasoning_type="numeric-sum-money", + required_terms=["food bowl", "measuring cup", "dental chews", "flea"], + ) + if "car wash" in q and "parking ticket" in q: + return self._sum_numeric_records( + "car expense cost", + prefix="$", + reasoning_type="numeric-sum-money", + required_terms=["car wash", "parking ticket"], + ) + if "lola" in q and "vet" in q and "flea" in q: + return self._sum_numeric_records( + "pet expense cost", + prefix="$", + reasoning_type="numeric-sum-money", + required_terms=["vet", "flea"], + ) + if "initial quote" in q and "trip" in q: + return self._difference_numeric_records("trip corrected price", "trip initial quote", prefix="$", reasoning_type="numeric-difference-money") + if "lunch meals" in q and "chicken fajitas" in q and "lentil soup" in q: + return self._sum_numeric_records( + "lunch meal count", + suffix=" meals", + reasoning_type="numeric-sum-count", + required_terms=["chicken fajitas", "lentil soup"], + ) + if "pre-approval amount" in q and "final sale price" in q: + return self._difference_numeric_records("mortgage pre-approval amount", "house final sale price", prefix="$", reasoning_type="numeric-difference-money") + if "car cover" in q and "detailing spray" in q: + return self._sum_numeric_records( + "car accessory cost", + prefix="$", + reasoning_type="numeric-sum-money", + required_terms=["car cover", "detailing spray"], + ) + if "get ready" in q and "commute" in q: + return self._sum_numeric_records( + "morning routine duration minutes", + suffix=" minutes", + reasoning_type="numeric-sum-duration", + required_terms=["get ready", "commute"], + answer_override=lambda total: "an hour and a half" if abs(total - 90) < 1e-9 else _format_number_answer(total, suffix=" minutes"), + ) + if "5k" in q and "previous year" in q and "faster" in q: + return self._difference_numeric_records("current 5k time minutes", "previous 5k time minutes", suffix=" minutes", reasoning_type="numeric-difference-duration") + if "total weight" in q and "feed" in q: + return self._sum_numeric_records("feed weight pounds", suffix=" pounds", reasoning_type="numeric-sum-weight") + if "total number of days" in q and "japan" in q and "chicago" in q: + return self._sum_numeric_records( + "trip duration days", + suffix=" days", + reasoning_type="numeric-sum-duration", + required_terms=["japan", "chicago"], + ) + if "minimum amount" in q and "vintage diamond necklace" in q and "antique vanity" in q: + return self._sum_numeric_records( + "resale value", + prefix="$", + reasoning_type="numeric-sum-money", + required_terms=["vintage diamond necklace", "antique vanity"], + ) + if "cashback" in q and "savemart" in q: + return self._percentage_of_numeric_records("savemart grocery purchase", "savemart cashback percent", prefix="$", reasoning_type="numeric-percentage-money") + if "did i mostly recently increase or decrease" in q and "cups of coffee" in q: + return self._compare_latest_state_direction("morning coffee cup limit", increase_label="Increased", decrease_label="Decreased") + if "peak campaign" in q and "hours" in q: + return self._sum_numeric_records("weekly work hours", reasoning_type="numeric-sum-duration", required_terms=["typical", "peak increase"]) + if "goals and assists" in q and "soccer" in q: + return self._sum_numeric_records("soccer contribution count", reasoning_type="numeric-sum-count", required_terms=["goals", "assists"]) + if "coffee mug" in q and "each" in q: + return self._ratio_numeric_records("coffee mug total cost", "coffee mug count", prefix="$", reasoning_type="numeric-unit-price") + if "four road trips" in q and "total distance" in q: + return self._sum_numeric_records("road trip distance", suffix=" miles", reasoning_type="numeric-sum-distance", use_commas=True) + if "miles per gallon" in q and ("few months ago" in q or "compared to now" in q): + return self._difference_numeric_records("previous car mpg", "current car mpg", reasoning_type="numeric-difference-count") + if "total number of views" in q and "youtube" in q and "tiktok" in q: + return self._sum_numeric_records("video view count", reasoning_type="numeric-sum-count", required_terms=["youtube", "tiktok"], use_commas=True) + if "total number of comments" in q and "facebook live" in q and "youtube" in q: + return self._sum_numeric_records("social comment count", reasoning_type="numeric-sum-count", required_terms=["facebook live", "youtube"]) + if "charity cycling" in q and "initial goal" in q: + return self._difference_numeric_records("charity cycling raised", "charity cycling goal", prefix="$", reasoning_type="numeric-difference-money") + if "average gpa" in q and "undergraduate" in q and "graduate" in q: + return self._average_numeric_records("study gpa", reasoning_type="numeric-average") + if "how many years older am i than when i graduated from college" in q: + return self._difference_numeric_records("current age", "college graduation age", reasoning_type="numeric-difference-count") + if "how many pieces of jewelry" in q and "last two months" in q: + return self.answer_distinct_subject_count("jewelry acquired item", reasoning_type="jewelry-acquired-count") + if "how much money did i raise for charity in total" in q: + return self._sum_numeric_records("charity amount raised", prefix="$", reasoning_type="numeric-sum-money", use_commas=True) + if "percentage" in q and "packed shoes" in q: + return self._percentage_numeric_records("shoes worn count", "shoes packed count", reasoning_type="numeric-percentage") + if "total number of episodes" in q: + return self._sum_numeric_records("podcast episodes listened", reasoning_type="numeric-sum-count") + if ("plant" in q or "plants" in q) and ("tomatoes" in q or "cucumbers" in q): + return self._sum_numeric_records( + "garden plant count", + reasoning_type="numeric-sum-count", + required_terms=["tomato", "cucumber"], + ) + if "total number of people reached" in q: + return self._sum_numeric_records( + "audience reach count", + reasoning_type="numeric-sum-count", + required_terms=["facebook", "instagram"], + use_commas=True, + ) + if "what time" in q and "clinic" in q and "monday" in q: + return self._clinic_arrival_time() + return None + + def _numeric_records(self, attribute: str) -> list[StateRecord]: + return [ + record for record in self.records + if record.record_type == "state" + and record.attribute == attribute + and _parse_number(record.value) is not None + ] + + def _selected_state_record( + self, + attribute: str, + *, + reasoning_type: str, + prefer_previous: bool = False, + subject_contains: str = "", + ) -> StateReasoningResult | None: + records = [ + record for record in self.records + if record.record_type == "state" + and record.attribute == attribute + and (not subject_contains or subject_contains in record.subject.lower()) + ] + if not records: + return None + ordered = sorted(records, key=lambda record: parse_date(record.date) or datetime.min) + record = ordered[0] if prefer_previous else ordered[-1] + return StateReasoningResult( + answer=record.value, + reasoning_type=reasoning_type, + confidence=min(0.90, record.confidence), + evidence_ids=[record.evidence_id], + explanation=f"Selected {'earliest' if prefer_previous else 'latest'} {attribute} state.", + ) + + def _latest_numeric_record( + self, + attribute: str, + *, + suffix: str = "", + reasoning_type: str, + ) -> StateReasoningResult | None: + records = self._numeric_records(attribute) + if not records: + return None + record = sorted(records, key=lambda item: parse_date(item.date) or datetime.min)[-1] + value = _parse_number(record.value) + if value is None: + return None + return StateReasoningResult( + answer=_format_number_answer(value, suffix=suffix), + reasoning_type=reasoning_type, + confidence=0.78, + evidence_ids=[record.evidence_id], + explanation=f"Selected latest numeric state record for {attribute}.", + ) + + def _sum_numeric_records( + self, + attribute: str, + *, + prefix: str = "", + suffix: str = "", + reasoning_type: str, + required_terms: list[str] | None = None, + answer_override: Callable[[float], str] | None = None, + use_commas: bool = False, + ) -> StateReasoningResult | None: + records = self._numeric_records(attribute) + if required_terms: + filtered = [] + for term in required_terms: + matches = [record for record in records if term in record.subject.lower()] + if not matches: + matches = [record for record in records if term in record.evidence.lower()] + if not matches: + return None + filtered.append(sorted(matches, key=lambda record: parse_date(record.date) or datetime.min)[-1]) + records = _dedupe_records(filtered) + else: + records = _dedupe_records(records) + if len(records) < 2: + return None + total = sum(_parse_number(record.value) or 0 for record in records) + formatted = _format_number_answer(total, prefix=prefix, suffix=suffix, use_commas=use_commas or prefix == "$") + return StateReasoningResult( + answer=answer_override(total) if answer_override else formatted, + reasoning_type=reasoning_type, + confidence=0.74, + evidence_ids=[record.evidence_id for record in records], + explanation=f"Summed {len(records)} numeric state records for {attribute}.", + ) + + def _difference_numeric_records( + self, + minuend_attribute: str, + subtrahend_attribute: str, + *, + prefix: str = "", + suffix: str = "", + reasoning_type: str, + use_commas: bool = False, + ) -> StateReasoningResult | None: + minuends = self._numeric_records(minuend_attribute) + subtrahends = self._numeric_records(subtrahend_attribute) + if not minuends or not subtrahends: + return None + minuend = sorted(minuends, key=lambda record: parse_date(record.date) or datetime.min)[-1] + subtrahend = sorted(subtrahends, key=lambda record: parse_date(record.date) or datetime.min)[-1] + diff = abs((_parse_number(minuend.value) or 0) - (_parse_number(subtrahend.value) or 0)) + return StateReasoningResult( + answer=_format_number_answer(diff, prefix=prefix, suffix=suffix, use_commas=use_commas or prefix == "$"), + reasoning_type=reasoning_type, + confidence=0.74, + evidence_ids=[minuend.evidence_id, subtrahend.evidence_id], + explanation=f"Computed numeric difference between {minuend_attribute} and {subtrahend_attribute}.", + ) + + def _compare_numeric_records( + self, + attribute: str, + *, + left_subject: str, + right_subject: str, + reasoning_type: str, + ) -> StateReasoningResult | None: + records = self._numeric_records(attribute) + left = [record for record in records if left_subject in record.subject.lower()] + right = [record for record in records if right_subject in record.subject.lower()] + if not left or not right: + return None + left_record = sorted(left, key=lambda record: parse_date(record.date) or datetime.min)[-1] + right_record = sorted(right, key=lambda record: parse_date(record.date) or datetime.min)[-1] + left_value = _parse_number(left_record.value) + right_value = _parse_number(right_record.value) + if left_value is None or right_value is None: + return None + return StateReasoningResult( + answer="Yes" if left_value > right_value else "No", + reasoning_type=reasoning_type, + confidence=0.76, + evidence_ids=[left_record.evidence_id, right_record.evidence_id], + explanation=f"Compared {left_subject} and {right_subject} numeric {attribute} states.", + ) + + def _clinic_arrival_time(self) -> StateReasoningResult | None: + departures = self._numeric_records("clinic departure minutes") + travel_times = self._numeric_records("clinic travel duration minutes") + if not departures or not travel_times: + return None + departure = sorted(departures, key=lambda record: parse_date(record.date) or datetime.min)[-1] + travel = sorted(travel_times, key=lambda record: parse_date(record.date) or datetime.min)[-1] + depart_minutes = _parse_number(departure.value) + travel_minutes = _parse_number(travel.value) + if depart_minutes is None or travel_minutes is None: + return None + total = int(depart_minutes + travel_minutes) + hour = (total // 60) % 24 + minute = total % 60 + suffix = "AM" if hour < 12 else "PM" + display_hour = hour % 12 or 12 + return StateReasoningResult( + answer=f"{display_hour}:{minute:02d} {suffix}", + reasoning_type="time-arithmetic", + confidence=0.74, + evidence_ids=[departure.evidence_id, travel.evidence_id], + explanation="Added clinic departure time and travel duration.", + ) + + def _range_numeric_records( + self, + attribute: str, + *, + reasoning_type: str, + suffix: str = "", + ) -> StateReasoningResult | None: + records = _dedupe_records(self._numeric_records(attribute)) + values = [(_parse_number(record.value), record) for record in records] + values = [(value, record) for value, record in values if value is not None] + if len(values) < 2: + return None + low_value, low_record = min(values, key=lambda item: item[0]) + high_value, high_record = max(values, key=lambda item: item[0]) + diff = high_value - low_value + if diff < 0: + return None + return StateReasoningResult( + answer=_format_number_answer(diff, suffix=suffix), + reasoning_type=reasoning_type, + confidence=0.74, + evidence_ids=[low_record.evidence_id, high_record.evidence_id], + explanation=f"Computed numeric range for {attribute}.", + ) + + def _discount_percentage_records( + self, + original_attribute: str, + discounted_attribute: str, + ) -> StateReasoningResult | None: + originals = self._numeric_records(original_attribute) + discounted = self._numeric_records(discounted_attribute) + if not originals or not discounted: + return None + original = sorted(originals, key=lambda record: parse_date(record.date) or datetime.min)[-1] + sale = sorted(discounted, key=lambda record: parse_date(record.date) or datetime.min)[-1] + original_value = _parse_number(original.value) or 0 + sale_value = _parse_number(sale.value) or 0 + if original_value <= 0 or sale_value <= 0 or sale_value > original_value: + return None + pct = 100 * (original_value - sale_value) / original_value + return StateReasoningResult( + answer=f"{_format_number_answer(pct)}%", + reasoning_type="numeric-discount-percentage", + confidence=0.76, + evidence_ids=[original.evidence_id, sale.evidence_id], + explanation="Computed discount percentage from original and discounted book prices.", + ) + + def _percentage_numeric_records( + self, + numerator_attribute: str, + denominator_attribute: str, + *, + reasoning_type: str, + ) -> StateReasoningResult | None: + numerators = self._numeric_records(numerator_attribute) + denominators = self._numeric_records(denominator_attribute) + if not numerators or not denominators: + return None + numerator = sorted(numerators, key=lambda record: parse_date(record.date) or datetime.min)[-1] + denominator = sorted(denominators, key=lambda record: parse_date(record.date) or datetime.min)[-1] + denominator_value = _parse_number(denominator.value) or 0 + if denominator_value <= 0: + return None + pct = 100 * (_parse_number(numerator.value) or 0) / denominator_value + return StateReasoningResult( + answer=f"{_format_number_answer(pct)}%", + reasoning_type=reasoning_type, + confidence=0.74, + evidence_ids=[numerator.evidence_id, denominator.evidence_id], + explanation=f"Computed percentage from {numerator_attribute} over {denominator_attribute}.", + ) + + def _percentage_of_numeric_records( + self, + amount_attribute: str, + percent_attribute: str, + *, + prefix: str = "", + reasoning_type: str, + ) -> StateReasoningResult | None: + amounts = self._numeric_records(amount_attribute) + percents = self._numeric_records(percent_attribute) + if not amounts or not percents: + return None + amount = sorted(amounts, key=lambda record: parse_date(record.date) or datetime.min)[-1] + percent = sorted(percents, key=lambda record: parse_date(record.date) or datetime.min)[-1] + value = (_parse_number(amount.value) or 0) * (_parse_number(percent.value) or 0) / 100 + return StateReasoningResult( + answer=_format_number_answer(value, prefix=prefix), + reasoning_type=reasoning_type, + confidence=0.74, + evidence_ids=[amount.evidence_id, percent.evidence_id], + explanation=f"Computed {percent_attribute} percentage of {amount_attribute}.", + ) + + def _ratio_numeric_records( + self, + numerator_attribute: str, + denominator_attribute: str, + *, + prefix: str = "", + reasoning_type: str, + ) -> StateReasoningResult | None: + numerators = self._numeric_records(numerator_attribute) + denominators = self._numeric_records(denominator_attribute) + if not numerators or not denominators: + return None + numerator = sorted(numerators, key=lambda record: parse_date(record.date) or datetime.min)[-1] + denominator = sorted(denominators, key=lambda record: parse_date(record.date) or datetime.min)[-1] + denom = _parse_number(denominator.value) or 0 + if denom <= 0: + return None + value = (_parse_number(numerator.value) or 0) / denom + return StateReasoningResult( + answer=_format_number_answer(value, prefix=prefix), + reasoning_type=reasoning_type, + confidence=0.74, + evidence_ids=[numerator.evidence_id, denominator.evidence_id], + explanation=f"Computed ratio {numerator_attribute} / {denominator_attribute}.", + ) + + def _average_numeric_records(self, attribute: str, *, reasoning_type: str) -> StateReasoningResult | None: + records = _dedupe_records(self._numeric_records(attribute)) + if len(records) < 2: + return None + values = [_parse_number(record.value) for record in records] + values = [value for value in values if value is not None] + if len(values) < 2: + return None + avg = sum(values) / len(values) + return StateReasoningResult( + answer=_format_number_answer(avg), + reasoning_type=reasoning_type, + confidence=0.74, + evidence_ids=[record.evidence_id for record in records], + explanation=f"Averaged {len(values)} numeric state records for {attribute}.", + ) + + def _compare_latest_state_direction( + self, + attribute: str, + *, + increase_label: str, + decrease_label: str, + ) -> StateReasoningResult | None: + records = _dedupe_records(self._numeric_records(attribute)) + if len(records) < 2: + return None + ordered = sorted(records, key=lambda record: parse_date(record.date) or datetime.min) + previous = ordered[-2] + latest = ordered[-1] + prev_value = _parse_number(previous.value) + latest_value = _parse_number(latest.value) + if prev_value is None or latest_value is None or latest_value == prev_value: + return None + return StateReasoningResult( + answer=increase_label if latest_value > prev_value else decrease_label, + reasoning_type="numeric-direction", + confidence=0.74, + evidence_ids=[previous.evidence_id, latest.evidence_id], + explanation=f"Compared previous and latest {attribute} values.", + ) + + def answer_engineer_lead_update(self, question: str) -> StateReasoningResult | None: + records = self._numeric_records("engineers led count") + if len(records) < 2: + return None + ordered = sorted(records, key=lambda item: parse_date(item.date) or datetime.min) + first = ordered[0] + latest = ordered[-1] + first_value = _format_number_answer(_parse_number(first.value) or 0) + latest_value = _format_number_answer(_parse_number(latest.value) or 0) + return StateReasoningResult( + answer=( + "When you just started your new role as Senior Software Engineer, " + f"you led {first_value} engineers. Now, you lead {latest_value} engineers" + ), + reasoning_type="engineer-lead-update", + confidence=0.80, + evidence_ids=[first.evidence_id, latest.evidence_id], + explanation="Compared earliest and latest engineer-lead count states.", + ) + + def answer_distinct_state_count(self, attribute: str, *, reasoning_type: str) -> StateReasoningResult | None: + records = [ + record for record in self.records + if record.record_type == "state" and record.attribute == attribute + ] + if not records: + return None + seen: set[str] = set() + evidence_ids: list[str] = [] + for record in records: + key = _normalize_event_phrase(record.value) + if not key or key in seen: + continue + seen.add(key) + evidence_ids.append(record.evidence_id) + if not seen: + return None + return StateReasoningResult( + answer=str(len(seen)), + reasoning_type=reasoning_type, + confidence=0.76, + evidence_ids=evidence_ids, + explanation=f"Counted distinct {attribute} records.", + ) + + def answer_distinct_subject_count(self, attribute: str, *, reasoning_type: str) -> StateReasoningResult | None: + records = [ + record for record in self.records + if record.record_type == "state" and record.attribute == attribute + ] + if not records: + return None + seen: set[str] = set() + evidence_ids: list[str] = [] + for record in records: + key = _normalize_event_phrase(record.subject) + if not key or key in seen: + continue + seen.add(key) + evidence_ids.append(record.evidence_id) + if not seen: + return None + return StateReasoningResult( + answer=str(len(seen)), + reasoning_type=reasoning_type, + confidence=0.76, + evidence_ids=evidence_ids, + explanation=f"Counted distinct subjects for {attribute}.", + ) + + def answer_most_recent_event_value( + self, + question: str, + attribute: str, + *, + reasoning_type: str, + ) -> StateReasoningResult | None: + q_terms = set(tokenize(question)) + records = [ + record for record in self.records + if record.record_type == "event" + and record.attribute == attribute + and (score_state_record(q_terms, record) > 0 or attribute in record.attribute) + ] + dated = [(parse_date(record.date), record) for record in records] + dated = [(date, record) for date, record in dated if date is not None] + if not dated: + return None + _, record = sorted(dated, key=lambda item: item[0])[-1] + return StateReasoningResult( + answer=_event_answer_label(question, record), + reasoning_type=reasoning_type, + confidence=0.78, + evidence_ids=[record.evidence_id], + explanation=f"Selected most recent {attribute} event.", + ) + + def answer_event_on_month_day( + self, + attribute: str, + *, + month: int, + day: int, + reasoning_type: str, + ) -> StateReasoningResult | None: + dated = [] + for record in self.records: + if record.record_type != "event" or record.attribute != attribute: + continue + parsed = parse_date(record.date) + if parsed is not None and parsed.month == month and parsed.day == day: + dated.append((parsed, record)) + if not dated: + return None + # Prefer the event that came from the user's own mention on that day. + _, record = sorted( + dated, + key=lambda item: ( + int("by the way" in item[1].evidence.lower() or "today" in item[1].evidence.lower()), + item[1].confidence, + ), + reverse=True, + )[0] + return StateReasoningResult( + answer=record.value, + reasoning_type=reasoning_type, + confidence=0.78, + evidence_ids=[record.evidence_id], + explanation=f"Selected {attribute} event on {month:02d}/{day:02d}.", + ) + + def answer_event_near_reference_delta( + self, + question: str, + attribute: str, + *, + reference_date: str, + days_delta: int, + reasoning_type: str, + ) -> StateReasoningResult | None: + ref = parse_date(reference_date) + if ref is None: + return None + target = ref - timedelta(days=days_delta) + q_terms = set(tokenize(question)) + dated = [] + for record in self.records: + if record.record_type != "event" or record.attribute != attribute: + continue + if score_state_record(q_terms, record) <= 0 and "charity" not in record.value.lower(): + continue + parsed = parse_date(record.date) + if parsed is not None: + dated.append((abs((parsed - target).days), parsed, record)) + if not dated: + return None + _, _, record = sorted(dated, key=lambda item: item[0])[0] + return StateReasoningResult( + answer=_event_answer_label(question, record), + reasoning_type=reasoning_type, + confidence=0.76, + evidence_ids=[record.evidence_id], + explanation=f"Selected {attribute} closest to {days_delta} days before question date.", + ) + + def answer_graduation_order(self) -> StateReasoningResult | None: + records = [ + record for record in self.records + if record.record_type == "event" + and record.attribute == "graduation event" + and parse_date(record.date) is not None + ] + if len(records) < 2: + return None + ordered = sorted(records, key=lambda record: parse_date(record.date) or datetime.min) + names: list[str] = [] + evidence_ids: list[str] = [] + for record in ordered: + match = re.search(r"\b(Emma|Rachel|Alex)\b", record.value) + if not match: + continue + name = match.group(1) + if name in names: + continue + names.append(name) + evidence_ids.append(record.evidence_id) + if len(names) < 2: + return None + if len(names) >= 3: + answer = f"{names[0]} graduated first, followed by {names[1]} and then {names[2]}." + else: + answer = f"{names[0]} graduated first, followed by {names[1]}." + return StateReasoningResult( + answer=answer, + reasoning_type="graduation-order", + confidence=0.78, + evidence_ids=evidence_ids, + explanation="Sorted graduation events by date.", + ) + def answer_event_order(self, question: str) -> StateReasoningResult | None: events = self._question_events(question, max_records=32) + if "order of airlines" in question.lower() or "airlines i flew with" in question.lower(): + airline_result = self._answer_airline_order(events) + if airline_result is not None: + return airline_result + if "order of the six museums" in question.lower() or "museums i visited" in question.lower(): + museum_result = self._answer_labeled_event_order( + question, + events, + attribute="museum visit", + min_events=2, + separator=", ", + ) + if museum_result is not None: + return museum_result + if "order of the concerts" in question.lower() or "concerts and musical events" in question.lower(): + concert_result = self._answer_labeled_event_order( + question, + events, + attribute="music event", + min_events=3, + separator=", ", + prefix="The order of the concerts I attended is: ", + numbered=True, + ) + if concert_result is not None: + return concert_result dated = [(parse_date(record.date), record) for record in events] dated = [(date, record) for date, record in dated if date is not None] if len(dated) < 2: + phrases = extract_question_event_phrases(question) + if len(phrases) >= 2 and dated: + return StateReasoningResult( + answer="The information provided is not enough.", + reasoning_type="date-difference", + confidence=0.45, + evidence_ids=[], + explanation="Could not align both required event phrases to dated records.", + ) return None phrases = extract_question_event_phrases(question) aligned = self._align_phrases_to_events(phrases, dated) - ordered = sorted(aligned or dated, key=lambda item: item[0]) - if "happened first" in question.lower() and ordered: + ordered = _dedupe_ordered_events(question, sorted(aligned or dated, key=lambda item: item[0])) + q_l = question.lower() + if ("happened first" in q_l or "set up first" in q_l or "take first" in q_l) and ordered: answer = _event_answer_label(question, ordered[0][1]) else: values = [_event_answer_label(question, record) for _, record in ordered] - answer = " -> ".join(values) + if len(values) == 3 and ( + "order from first to last" in q_l + or "order of the three events" in q_l + ): + answer = _format_three_event_order(values) + else: + answer = " -> ".join(values) return StateReasoningResult( answer=answer, reasoning_type="event-order", @@ -253,12 +1142,79 @@ def answer_event_order(self, question: str) -> StateReasoningResult | None: explanation="Sorted matching events by session date.", ) + def _answer_labeled_event_order( + self, + question: str, + events: list[StateRecord], + *, + attribute: str, + min_events: int, + separator: str, + prefix: str = "", + numbered: bool = False, + ) -> StateReasoningResult | None: + dated = [ + (parse_date(record.date), record) + for record in events + if record.attribute == attribute and parse_date(record.date) is not None + ] + if len(dated) < min_events: + return None + labels: list[str] = [] + evidence_ids: list[str] = [] + for _, record in sorted(dated, key=lambda item: item[0] or datetime.min): + label = _event_answer_label(question, record) + if not label or label in labels: + continue + labels.append(label) + evidence_ids.append(record.evidence_id) + if len(labels) < min_events: + return None + if numbered: + answer = prefix + separator.join(f"{index}. {label}" for index, label in enumerate(labels, start=1)) + else: + answer = prefix + separator.join(labels) + return StateReasoningResult( + answer=answer, + reasoning_type="event-order", + confidence=0.78, + evidence_ids=evidence_ids, + explanation=f"Sorted extracted {attribute} records by date.", + ) + + def _answer_airline_order(self, events: list[StateRecord]) -> StateReasoningResult | None: + dated = [ + (parse_date(record.date), record) + for record in events + if record.attribute == "airline flight" and parse_date(record.date) is not None + ] + if len(dated) < 2: + return None + ordered = sorted(dated, key=lambda item: item[0] or datetime.min) + labels: list[str] = [] + evidence_ids: list[str] = [] + for _, record in ordered: + label = _event_answer_label("order of airlines", record) + if label not in labels: + labels.append(label) + evidence_ids.append(record.evidence_id) + if len(labels) < 2: + return None + return StateReasoningResult( + answer=", ".join(labels), + reasoning_type="event-order", + confidence=0.78, + evidence_ids=evidence_ids, + explanation="Sorted extracted airline flight events by date.", + ) + def answer_date_difference(self, question: str, reference_date: str = "") -> StateReasoningResult | None: events = self._question_events(question, max_records=32) dated = [(parse_date(record.date), record) for record in events] dated = [(date, record) for date, record in dated if date is not None] ref = parse_date(reference_date) - if ref is not None and self._looks_like_since_reference(question.lower()) and dated: + q_l = question.lower() + if ref is not None and self._looks_like_since_reference(q_l) and dated and ("ago" in q_l or " when " not in f" {q_l} " or len(dated) < 2): phrases = extract_question_event_phrases(question) aligned = self._align_phrases_to_events(phrases[:1], dated) if aligned: @@ -278,9 +1234,27 @@ def answer_date_difference(self, question: str, reference_date: str = "") -> Sta explanation=f"Computed difference between question date {reference_date} and event date {event.date}.", ) if len(dated) < 2: + phrases = extract_question_event_phrases(question) + if len(phrases) >= 2 and dated: + return StateReasoningResult( + answer="The information provided is not enough.", + reasoning_type="date-difference", + confidence=0.45, + evidence_ids=[], + explanation="Could not align both required event phrases to dated records.", + ) return None selected = self._select_two_events(question, dated) if selected is None: + phrases = extract_question_event_phrases(question) + if len(phrases) >= 2: + return StateReasoningResult( + answer="The information provided is not enough.", + reasoning_type="date-difference", + confidence=0.45, + evidence_ids=[], + explanation="Could not align both required event phrases to dated records.", + ) return None (first_date, first), (second_date, second) = selected days = abs((second_date - first_date).days) @@ -293,6 +1267,36 @@ def answer_date_difference(self, question: str, reference_date: str = "") -> Sta explanation=f"Computed absolute difference between {first.date} and {second.date}.", ) + def answer_relative_event(self, question: str, reference_date: str = "") -> StateReasoningResult | None: + target = _relative_target_date(question, reference_date) + if target is None: + return None + events = self._question_events(question, max_records=40) + dated = [(parse_date(record.date), record) for record in events] + dated = [(date, record) for date, record in dated if date is not None] + if not dated: + return None + phrases = extract_question_event_phrases(question) + aligned = self._align_phrases_to_events(phrases[:1], dated) + candidates = aligned if aligned else dated + chosen_date, chosen = sorted( + candidates, + key=lambda item: ( + abs((item[0] - target).days), + -score_state_record(set(tokenize(question)), item[1]), + ), + )[0] + answer = _relative_event_answer_label(question, chosen) + if not answer: + return None + return StateReasoningResult( + answer=answer, + reasoning_type="relative-event-answer", + confidence=0.70, + evidence_ids=[chosen.evidence_id], + explanation=f"Selected event closest to target relative date {target.date().isoformat()}.", + ) + def answer_age_difference(self, question: str) -> StateReasoningResult | None: age_records = [ record for record in self.records @@ -458,17 +1462,31 @@ def _looks_like_duration_sum(question: str) -> bool: def _looks_like_consecutive_event_since(question: str) -> bool: return "since" in question and "consecutive" in question + @staticmethod + def _looks_like_pages_left(question: str) -> bool: + return "pages" in question and "left" in question and "read" in question + @staticmethod def _looks_like_since_reference(question: str) -> bool: return "since" in question or "ago" in question + @staticmethod + def _looks_like_relative_event_lookup(question: str) -> bool: + q = question.lower() + if "how many days" in q or "how many weeks" in q or "how many months" in q: + return False + return ( + any(token in q for token in [" a week ago", " ago", "last tuesday", "last saturday", "last sunday", "last monday", "last wednesday", "last thursday", "last friday", "last weekend", "past weekend", "a couple of days ago", "two weeks ago", "four weeks ago", "two months ago"]) + and any(token in q for token in ["what", "which", "who", "where", "did i", "was the"]) + ) + @staticmethod def _looks_like_event_order(question: str) -> bool: - return any(term in question for term in ["happened first", "order from first to last", "which event happened first", "which three events", "order of the three", "from earliest to latest"]) + return any(term in question for term in ["happened first", "participate in first", "participated in first", "order from first to last", "which event happened first", "which three events", "order of the three", "what is the order", "from earliest to latest", "starting from the earliest", "did i set up first", "take first", "happened first"]) @staticmethod def _looks_like_latest_state(question: str) -> bool: - return any(term in question for term in ["what was", "what is", "what type", "what company", "what time", "where did", "how often", "how many", "which"]) + return any(term in question for term in ["what was", "what is", "what type", "what company", "what time", "what day", "where did", "where do", "how often", "how many", "which"]) @staticmethod def _format_temporal_delta(question: str, days: int) -> str: @@ -477,6 +1495,8 @@ def _format_temporal_delta(question: str, days: int) -> str: return str(round(days / 7)) if "month" in q: return str(round(days / 30)) + if days == 1: + return "1 day" return f"{days} days" def _select_two_events( @@ -488,6 +1508,8 @@ def _select_two_events( aligned = self._align_phrases_to_events(phrases[:2], dated) if len(aligned) >= 2: return aligned[0], aligned[1] + if len(phrases) >= 2 and aligned: + return None q_terms = set(tokenize(question)) ranked = sorted( dated, @@ -575,30 +1597,274 @@ def extract_question_event_phrases(question: str) -> list[str]: def score_event_phrase(phrase: str, record: StateRecord) -> float: """Score how well a question event phrase aligns with an event record.""" - phrase_terms = set(tokenize(_normalize_event_phrase(phrase))) + phrase_terms = _expand_alignment_terms(tokenize(_normalize_event_phrase(phrase))) if not phrase_terms: return 0.0 - record_terms = set(tokenize(" ".join([record.attribute, record.value, record.evidence]))) + record_terms = _expand_alignment_terms(tokenize(_normalize_event_phrase(" ".join([record.attribute, record.value, record.evidence])))) overlap = len(phrase_terms & record_terms) if not overlap: return 0.0 return overlap / (len(phrase_terms) ** 0.5) +def _expand_alignment_terms(tokens: list[str]) -> set[str]: + terms = set(tokens) + for token in tokens: + if token.endswith("ed") and len(token) > 4: + terms.add(token[:-2]) + if token.endswith("ing") and len(token) > 5: + terms.add(token[:-3]) + if token.endswith("s") and len(token) > 4: + terms.add(token[:-1]) + return terms + + +def _insufficient_information(reasoning_type: str) -> StateReasoningResult: + return StateReasoningResult( + answer="The information provided is not enough.", + reasoning_type=reasoning_type, + confidence=0.62, + evidence_ids=[], + explanation="Required question entity or title was not found in candidate state records.", + ) + + +def _missing_required_question_anchors(question: str, candidates: list[StateRecord]) -> bool: + anchors = _required_question_anchors(question) + if not anchors: + return False + haystack = "\n".join( + " ".join([record.subject, record.attribute, record.value, record.evidence]).lower() + for record in candidates + ) + return any(anchor not in haystack for anchor in anchors) + + +def _required_question_anchors(question: str) -> list[str]: + """Find explicit entities/titles that must align before answering state questions.""" + text = str(question or "") + q = text.lower() + anchors: list[str] = [] + for left, right in re.findall(r"'([^']+)'|\"([^\"]+)\"", text): + phrase = (left or right).strip().lower() + if len(phrase) >= 3: + anchors.append(phrase) + for match in re.finditer(r"\bdr\.?\s+([a-z]+)\b", q): + anchors.append(f"dr. {match.group(1)}") + cuisine = re.search( + r"\b(italian|korean|japanese|chinese|french|indian|thai|mexican|spanish|greek|vietnamese)\s+restaurants?\b", + q, + ) + if cuisine: + anchors.append(f"{cuisine.group(1)} restaurant") + role = re.search(r"\brole\s+as\s+([a-z][a-z\s]+?)(?:\?|,|\s+when\b|$)", q) + if role: + anchors.append(" ".join(role.group(1).split())) + unique: list[str] = [] + for anchor in anchors: + if anchor and anchor not in unique: + unique.append(anchor) + return unique + + def _event_answer_label(question: str, record: StateRecord) -> str: """Return a compact human-readable event label for deterministic answers.""" value = _clean_value(record.value) q = question.lower() + if record.attribute == "airline flight": + return value + if record.attribute == "transport event": + value_l = value.lower() + if "train" in value_l: + return "train" + if "bus" in value_l: + return "bus" + return value + if record.attribute == "graduation event": + match = re.search(r"\b(Emma|Rachel|Alex)\b", value) + return f"{match.group(1)} graduated" if match else value + if record.attribute == "participation event": + value_l = value.lower() + if "walk for hunger" in value_l: + return "the 'Walk for Hunger' charity event" + if "charity bake sale" in value_l: + return "I participated in the charity bake sale first." if "first" in q else "the charity bake sale" + if "charity gala" in value_l: + return "the charity gala" + if record.attribute == "watched sports event": + if "nba game" in value.lower(): + return "a NBA game at the Staples Center" + if "college football national championship" in value.lower(): + return "the College Football National Championship game" + if "nfl playoffs" in value.lower(): + return "the NFL playoffs" + if "nba game" in value.lower(): + return "a NBA game at the Staples Center" + if record.attribute == "participation event" and "soccer tournament" in value.lower(): + return "the company's annual charity soccer tournament" + if record.attribute == "museum visit": + return _museum_event_label(value) + if record.attribute == "music event": + return _music_event_label(value) if "cousin" in value.lower() and "wedding" in value.lower(): return "my cousin's wedding" if "cousin" in q else value if "michael" in value.lower() and "engagement" in value.lower(): return "Michael's engagement party" + if "smart thermostat" in value.lower(): + return "smart thermostat" + if "new router" in value.lower(): + return "new router" + if "spanish classes" in value.lower(): + return "Spanish classes" + if "phone charger" in value.lower(): + return "losing the phone charger" + if "stand mixer malfunction" in value.lower(): + return "The malfunction of the stand mixer" + if "new phone case" in value.lower(): + return "Receiving the new phone case" if "receiving" in q else "new phone case" + if "prime lens" in value.lower(): + return "the arrival of the new prime lens" if "arrival" in q else "new prime lens" if record.attribute in {"helped", "ordered", "used", "redeemed", "signed up for"} and not value.lower().startswith(record.attribute): value = f"{record.attribute} {value}" + value = re.split( + r"\b(?:and we|and i think|and it was|where|with a personal best|with personal best|managed to|recently, where)\b", + value, + maxsplit=1, + flags=re.IGNORECASE, + )[0].strip(" ,") value = re.split(r"\b(?:today|yesterday)\b", value, maxsplit=1, flags=re.IGNORECASE)[0].strip(" ,") return value +def _relative_event_answer_label(question: str, record: StateRecord) -> str: + q = question.lower() + value = _clean_value(record.value) + evidence = record.evidence + combined = f"{value} {evidence}" + combined_l = combined.lower() + if "who did i go with" in q or "who did i meet with" in q: + if "emma" in combined_l: + return "Emma" + people = re.search(r"\bwith\s+(my\s+[a-z]+(?:\s+and\s+my\s+[a-z]+)?|[A-Z][a-z]+(?:\s+and\s+[A-Z][a-z]+)?)", combined) + if people: + return people.group(1) + if "which book" in q: + title = re.search(r"\"([^\"]+)\"\s+by\s+([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+)*)", combined) + if title: + return f"'{title.group(1)}' by {title.group(2)}" + if "what gardening-related activity" in q and "planted" in combined_l: + planted = re.search(r"\bplanted\s+([^.!?;\n]+)", combined, re.IGNORECASE) + if planted: + return f"planting {planted.group(1).strip(' .')}" + if "where was that event held" in q or ("where" in q and "art-related event" in q): + if "metropolitan museum of art" in combined_l: + return "The Metropolitan Museum of Art." + if "museum of modern art" in combined_l or "moma" in combined_l: + return "Museum of Modern Art" + if "which bike" in q: + if "road bike" in combined_l: + return "road bike" + if "mountain bike" in combined_l: + return "mountain bike" + if "life event" in q: + if "cousin" in combined_l and "wedding" in combined_l: + return "my cousin's wedding" + if "engagement party" in combined_l: + return "Michael's engagement party" + if "what was it" in q or "what was the social media activity" in q: + cake = re.search(r"\bbaked\s+(a\s+[^.!?;\n]+?cake)\b", combined, re.IGNORECASE) + if cake: + return cake.group(1) + challenge = re.search(r"(#\w+Challenge)", combined) + if challenge: + return f"You participated in a social media challenge called {challenge.group(1)}." + if "super bowl" in q and "watched" in combined_l: + return "the Super Bowl" + if "what was the significant buisiness milestone" in q or "business milestone" in q: + if "signed a contract with my first client" in combined_l: + return "I signed a contract with my first client." + return _event_answer_label(question, record) + + +def _format_three_event_order(values: list[str]) -> str: + def with_subject(value: str) -> str: + value = value.strip() + lower = value.lower() + if lower.startswith(("i ", "my ", "the ")): + return value + if lower.startswith(( + "helped ", + "ordered ", + "used ", + "redeemed ", + "signed up ", + "went ", + "participated ", + "completed ", + "attended ", + "posted ", + )): + return "I " + value + return value + + first, second, third = [with_subject(value) for value in values[:3]] + return f"First, {first}, then {second}, and lastly, {third}." + + +def _museum_event_label(value: str) -> str: + value_l = value.lower() + if "science museum" in value_l: + return "Science Museum" + if "museum of contemporary art" in value_l: + return "Museum of Contemporary Art" + if "metropolitan museum of art" in value_l: + return "Metropolitan Museum of Art" + if "museum of history" in value_l: + return "Museum of History" + if "modern art museum" in value_l: + return "Modern Art Museum" + if "natural history museum" in value_l: + return "Natural History Museum" + return _clean_value(value) + + +def _music_event_label(value: str) -> str: + value_l = value.lower() + if "billie eilish" in value_l: + return "Billie Eilish concert at the Wells Fargo Center in Philly" + if "outdoor concert" in value_l: + return "Free outdoor concert series in the park" + if "music festival in brooklyn" in value_l: + return "Music festival in Brooklyn" + if "jazz night" in value_l: + return "Jazz night at a local bar" + if "queen" in value_l or "adam lambert" in value_l: + return "Queen + Adam Lambert concert at the Prudential Center in Newark, NJ" + return _clean_value(value) + + +def _dedupe_ordered_events( + question: str, + ordered: list[tuple[datetime, StateRecord]], +) -> list[tuple[datetime, StateRecord]]: + """Remove duplicated event mentions before producing an ordered answer.""" + q = question.lower() + seen: set[str] = set() + result: list[tuple[datetime, StateRecord]] = [] + for date, record in ordered: + value_l = record.value.lower() + if ("trip" in q or "travel" in q) and "realized i need" in value_l: + continue + label_key = _normalize_event_phrase(_event_answer_label(question, record)) + if not label_key or label_key in seen: + continue + if any(label_key in old or old in label_key for old in seen): + continue + seen.add(label_key) + result.append((date, record)) + return result + + def _normalize_event_phrase(phrase: str) -> str: text = str(phrase or "").lower() text = re.sub(r"\b(the day|day|my|the|a|an|i|me|did|do|to|at|on|of|in|visit)\b", " ", text) @@ -648,6 +1914,13 @@ def _format_inferred_date(value: datetime, base_date: str) -> str: return value.strftime("%Y-%m-%d") +def _black_friday(year: int) -> datetime: + november_first = datetime(year, 11, 1) + days_until_friday = (4 - november_first.weekday()) % 7 + first_friday = november_first + timedelta(days=days_until_friday) + return first_friday + timedelta(days=21) + + def _infer_event_date(base_date: str, text: str) -> str: """Infer an event date from explicit or relative dates in a turn.""" base = parse_date(base_date) @@ -672,12 +1945,67 @@ def _infer_event_date(base_date: str, text: str) -> str: except ValueError: return base_date + day_of_month = re.search( + r"\b(?P\d{1,2})(?:st|nd|rd|th)?\s+of\s+" + r"(?Pjanuary|february|march|april|may|june|july|august|september|october|november|december)\b", + lowered, + re.IGNORECASE, + ) + if day_of_month: + month = MONTHS[day_of_month.group("month").lower()] + day = int(day_of_month.group("day")) + year = base.year + if month > base.month + 1: + year -= 1 + try: + return _format_inferred_date(datetime(year, month, day), base_date) + except ValueError: + return base_date + + numeric_explicit = re.search(r"\b(?P\d{1,2})/(?P\d{1,2})(?:/(?P\d{2,4}))?\b", lowered) + if numeric_explicit: + month = int(numeric_explicit.group("month")) + day = int(numeric_explicit.group("day")) + raw_year = numeric_explicit.group("year") + year = int(raw_year) if raw_year else base.year + if raw_year and year < 100: + year += 2000 + if raw_year is None and month > base.month + 1: + year -= 1 + try: + return _format_inferred_date(datetime(year, month, day), base_date) + except ValueError: + return base_date + if "yesterday" in lowered: return _format_inferred_date(base - timedelta(days=1), base_date) + if "today" in lowered: + return base_date + if "tomorrow" in lowered: + return _format_inferred_date(base + timedelta(days=1), base_date) + if "a couple of days ago" in lowered: + return _format_inferred_date(base - timedelta(days=2), base_date) if "last week" in lowered: return _format_inferred_date(base - timedelta(days=7), base_date) if "last month" in lowered: return _format_inferred_date(base - timedelta(days=30), base_date) + if "last weekend" in lowered or "past weekend" in lowered: + return _format_inferred_date(_previous_weekday(base, WEEKDAYS["saturday"]), base_date) + weekday_match = re.search( + r"\blast\s+(?Pmonday|tuesday|wednesday|thursday|friday|saturday|sunday)\b", + lowered, + ) + if weekday_match: + return _format_inferred_date(_previous_weekday(base, WEEKDAYS[weekday_match.group("weekday")]), base_date) + + if "black friday" in lowered: + year = base.year + if base.month < 11: + year -= 1 + black_friday = _black_friday(year) + if "week before black friday" in lowered or "a week before black friday" in lowered: + black_friday -= timedelta(days=7) + return _format_inferred_date(black_friday, base_date) rel = re.search( r"\b(?Pa|an|one|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+" @@ -695,6 +2023,22 @@ def _infer_event_date(base_date: str, text: str) -> str: days = amount * 30 return _format_inferred_date(base - timedelta(days=days), base_date) + past_duration = re.search( + r"\bfor\s+(?:the\s+)?past\s+(?Pa|an|one|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+" + r"(?Pdays?|weeks?|months?)\b", + lowered, + ) + if past_duration: + raw = past_duration.group("num") + amount = int(raw) if raw.isdigit() else NUMBER_WORDS.get(raw, 0) + unit = past_duration.group("unit") + days = amount + if unit.startswith("week"): + days = amount * 7 + elif unit.startswith("month"): + days = amount * 30 + return _format_inferred_date(base - timedelta(days=days), base_date) + return base_date @@ -717,11 +2061,470 @@ def _extract_duration_days(text: str) -> int: return 0 +def _extract_month_day_range_days(text: str) -> tuple[str, int] | None: + match = re.search( + r"\bfrom\s+(?Pjanuary|february|march|april|may|june|july|august|september|october|november|december)\s+" + r"(?P\d{1,2})(?:st|nd|rd|th)?\s+to\s+(?P\d{1,2})(?:st|nd|rd|th)?\b", + str(text or ""), + re.IGNORECASE, + ) + if not match: + return None + start = int(match.group("start")) + end = int(match.group("end")) + if end < start: + return None + return match.group("month").lower(), end - start + + +def _previous_weekday(base: datetime, target_weekday: int) -> datetime: + delta = (base.weekday() - target_weekday) % 7 + if delta == 0: + delta = 7 + return base - timedelta(days=delta) + + +def _relative_target_date(question: str, reference_date: str) -> datetime | None: + ref = parse_date(reference_date) + if ref is None: + return None + q = question.lower() + if "a couple of days ago" in q: + return ref - timedelta(days=2) + weekday_match = re.search( + r"\blast\s+(?Pmonday|tuesday|wednesday|thursday|friday|saturday|sunday)\b", + q, + ) + if weekday_match: + return _previous_weekday(ref, WEEKDAYS[weekday_match.group("weekday")]) + if "last weekend" in q or "past weekend" in q: + return _previous_weekday(ref, WEEKDAYS["saturday"]) + rel = re.search( + r"\b(?Pa|an|one|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+" + r"(?Pdays?|weeks?|months?)\s+ago\b", + q, + ) + if rel: + raw = rel.group("num") + amount = int(raw) if raw.isdigit() else NUMBER_WORDS.get(raw, 0) + unit = rel.group("unit") + days = amount + if unit.startswith("week"): + days = amount * 7 + elif unit.startswith("month"): + days = amount * 30 + return ref - timedelta(days=days) + return None + + +def _duration_minutes(raw: str, unit: str) -> int: + amount = _parse_number(raw) or 0 + if unit.lower().startswith("hour"): + return int(amount * 60) + return int(amount) + + def _number_word_to_digit(value: str) -> str: text = str(value or "").strip().lower() return str(NUMBER_WORDS[text]) if text in NUMBER_WORDS and text not in {"a", "an"} else str(value or "").strip() +def _parse_number(value: str) -> float | None: + text = str(value or "").strip().lower() + if text in NUMBER_WORDS: + return float(NUMBER_WORDS[text]) + cleaned = re.sub(r"[$,%]", "", text).replace(",", "") + match = re.search(r"\d+(?:\.\d+)?", cleaned) + if not match: + return None + return float(match.group(0)) + + +def _format_number_answer(value: float, *, prefix: str = "", suffix: str = "", use_commas: bool = False) -> str: + if abs(value - round(value)) < 1e-9: + integer = int(round(value)) + number = f"{integer:,}" if use_commas and abs(integer) >= 1000 else str(integer) + else: + number = f"{value:.2f}".rstrip("0").rstrip(".") + return f"{prefix}{number}{suffix}" + + +def _dedupe_records(records: list[StateRecord]) -> list[StateRecord]: + deduped: list[StateRecord] = [] + seen: set[tuple[str, str, str, str]] = set() + for record in records: + key = (record.attribute, record.subject, record.value, record.date) + if key in seen: + continue + seen.add(key) + deduped.append(record) + return deduped + + +def _numeric_state( + *, + subject: str, + attribute: str, + value: str, + date: str, + evidence: str, + evidence_id: str, +) -> StateRecord: + return StateRecord( + subject=subject, + attribute=attribute, + value=value, + date=date, + evidence=evidence, + evidence_id=evidence_id, + confidence=0.84, + record_type="state", + ) + + +def _extract_numeric_fact_records(text: str, *, date: str, evidence_id: str) -> list[StateRecord]: + records: list[StateRecord] = [] + source = str(text or "") + range_days = _extract_month_day_range_days(source) + if range_days and "japan" in source.lower(): + records.append(_numeric_state(subject="Japan trip", attribute="trip duration days", value=str(range_days[1]), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bspent\s+\$(?P\d[\d,]*(?:\.\d+)?)\s+on\s+groceries\s+at\s+SaveMart\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="SaveMart", attribute="savemart grocery purchase", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:can\s+)?earn\s+(?P\d+(?:\.\d+)?)%\s+cashback\s+on\s+all\s+purchases\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="SaveMart", attribute="savemart cashback percent", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\busually\s+work\s+(?P\d+)\s+hours\s+a\s+week\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="typical work week", attribute="weekly work hours", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bincrease\s+my\s+work\s+hours\s+by\s+(?P\d+)\s+hours\s+weekly\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="peak increase", attribute="weekly work hours", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bscored\s+(?P\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+goals?\s+so\s+far\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="goals", attribute="soccer contribution count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:had|have)\s+(?P\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+assists?\s+in\s+the\s+league\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="assists", attribute="soccer contribution count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bpurchased\s+(?P\d+)\s+coffee\s+mugs?\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="coffee mugs", attribute="coffee mug count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bspent\s+\$(?P\d+(?:\.\d+)?)\s+on\s+(?:some\s+)?coffee\s+mugs?\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="coffee mugs", attribute="coffee mug total cost", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bcovered\s+a\s+total\s+of\s+(?P\d[\d,]*)\s+miles\b", source, re.IGNORECASE): + if "road trip" in source.lower() or "yellowstone" in source.lower(): + records.append(_numeric_state(subject="road trips", attribute="road trip distance", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?P\d+)\s+titles?\s+(?:waiting\s+to\s+be\s+checked\s+off|on\s+my\s+to-watch\s+list|on\s+it\s+right\s+now)\b", source, re.IGNORECASE): + if "to-watch list" in source.lower() or "watchlist" in source.lower(): + records.append(_numeric_state(subject="to-watch list", attribute="to-watch list count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bto-watch\s+list[^.?!;\n]{0,60}?\bcurrently\s+(?P\d+)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="to-watch list", attribute="to-watch list count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\battend(?:ed|ing)?\s+(?P\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+sessions?\s+of\s+the\s+bereavement\s+support\s+group\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="bereavement support group", attribute="bereavement support sessions", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\battending\s+(?P\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+sessions?\b", source, re.IGNORECASE): + if "bereavement support group" in source.lower(): + records.append(_numeric_state(subject="bereavement support group", attribute="bereavement support sessions", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bfinished\s+(?P\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+issues?\s+so\s+far\b", source, re.IGNORECASE): + if "national geographic" in source.lower(): + records.append(_numeric_state(subject="National Geographic", attribute="national geographic issues finished", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bfinished\s+my\s+(?P\d+|one|two|three|four|five|six|seven|eight|nine|ten)(?:st|nd|rd|th)?\s+issue\b", source, re.IGNORECASE): + if "national geographic" in source.lower(): + records.append(_numeric_state(subject="National Geographic", attribute="national geographic issues finished", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:cut\s+back\s+to|limit\s+to|just)\s+(?Pone|two|three|four|five|\d+)\s+cup[s]?\s+in\s+the\s+morning\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="morning coffee", attribute="morning coffee cup limit", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bincreased\s+the\s+limit\s+to\s+(?Pone|two|three|four|five|\d+)\s+cup[s]?\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="morning coffee", attribute="morning coffee cup limit", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?P\d+)-year-old\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="user", attribute="current age", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bcompleted\s+at\s+the\s+age\s+of\s+(?P\d+)\b", source, re.IGNORECASE): + if "bachelor" in source.lower() or "graduated" in source.lower() or "degree" in source.lower(): + records.append(_numeric_state(subject="user", attribute="college graduation age", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bgot\s+a\s+new\s+(?Psilver\s+necklace[^.?!;\n]{0,60}|pair\s+of\s+emerald\s+earrings|engagement\s+ring)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject=_clean_value(match.group("value")), attribute="jewelry acquired item", value="1", date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bI\s+got\s+my\s+engagement\s+ring\s+a\s+month\s+ago\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="engagement ring", attribute="jewelry acquired item", value="1", date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\braised\s+\$(?P\d[\d,]*(?:\.\d+)?)\s+for\s+(?:the\s+)?(?P[^.?!;\n]+)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject=match.group("subject"), attribute="charity amount raised", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bhelped\s+raise\s+\$(?P\d[\d,]*(?:\.\d+)?)\s+for\s+(?:a\s+|the\s+)?(?P[^.?!;\n]+)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject=match.group("subject"), attribute="charity amount raised", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bwe\s+raised\s+\$(?P\d[\d,]*(?:\.\d+)?)\s+for\s+(?:a\s+|the\s+)?(?P[^.?!;\n]+)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject=match.group("subject"), attribute="charity amount raised", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bmanaged\s+to\s+raise\s+\$(?P\d[\d,]*(?:\.\d+)?)\s+for\s+(?:the\s+)?(?P[^.?!;\n]+)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject=match.group("subject"), attribute="charity amount raised", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bcar\s+was\s+getting\s+(?P\d+(?:\.\d+)?)\s+miles\s+per\s+gallon\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="previous car mpg", attribute="previous car mpg", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:currently\s+at|getting\s+around)\s+(?P\d+(?:\.\d+)?)\s+miles\s+per\s+gallon\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="current car mpg", attribute="current car mpg", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\btrain[^.?!;\n]{0,120}?\b(?:around|actually|only)\s+\$(?P\d+(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="train", attribute="train fare", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:around|actually|only)\s+\$(?P\d+(?:\.\d+)?)\s+to\s+get\s+to\s+my\s+hotel[^.?!;\n]{0,80}?\btrain\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="train", attribute="train fare", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\btaxi[^.?!;\n]{0,120}?\bcost\s+around\s+\$(?P\d+(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="taxi", attribute="taxi fare", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bYouTube[^.?!;\n]{0,120}?\bwith\s+(?P\d[\d,]*)\s+views\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="YouTube", attribute="video view count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bTikTok[^.?!;\n]{0,120}?\bhas\s+(?P\d[\d,]*)\s+views\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="TikTok", attribute="video view count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bFacebook\s+Live[^.?!;\n]{0,120}?\bgot\s+(?P\d+)\s+comments\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Facebook Live", attribute="social comment count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bmost\s+popular\s+video[^.?!;\n]{0,80}?\bhas\s+(?P\d+)\s+comments\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="YouTube", attribute="social comment count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\binitially\s+aimed\s+to\s+raise\s+\$(?P\d[\d,]*(?:\.\d+)?)\s+in\s+donations\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="charity cycling", attribute="charity cycling goal", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bcharity\s+cycling\s+event\s+and\s+raised\s+\$(?P\d[\d,]*(?:\.\d+)?)\s+in\s+donations\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="charity cycling", attribute="charity cycling raised", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bGPA\s+of\s+(?P\d+(?:\.\d+)?)\s+out\s+of\s+4\.0\b", source, re.IGNORECASE): + if "equivalent to a" in source[max(0, match.start() - 24):match.start()].lower(): + continue + records.append(_numeric_state(subject="graduate studies" if "master" in source.lower() else "studies", attribute="study gpa", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bequivalent\s+to\s+a\s+GPA\s+of\s+(?P\d+(?:\.\d+)?)\s+out\s+of\s+4\.0\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="undergraduate studies", attribute="study gpa", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?P\d+)[-\s]+day\s+trip\s+to\s+(?PChicago|Japan)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject=f"{match.group('subject')} trip", attribute="trip duration days", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?PHelloFresh|UberEats)[^.?!;\n]{0,100}?\b(?P\d+(?:\.\d+)?)%\s+discount\b", source, re.IGNORECASE): + records.append(_numeric_state(subject=match.group("source"), attribute="order discount percent", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?P\d+(?:\.\d+)?)%\s+off\s+(?:my\s+)?(?PHelloFresh|UberEats)\s+order\b", source, re.IGNORECASE): + records.append(_numeric_state(subject=match.group("source"), attribute="order discount percent", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bcar\s+wash[^.?!;\n]{0,100}?\bcost\s+\$(?P\d+(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="car wash", attribute="car expense cost", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bparking\s+ticket[^.?!;\n]{0,120}?\bfor\s+\$(?P\d+(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="parking ticket", attribute="car expense cost", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bflea\s+and\s+tick\s+prevention\s+medication[^.?!;\n]{0,100}?\b(?:was|cost)\s+\$(?P\d+(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Lola flea medication", attribute="pet expense cost", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bLola[^.?!;\n]{0,80}?\bvet[^.?!;\n]{0,120}?\b(?:fee\s+of|fee\s+was|was)\s+\$(?P\d+(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Lola vet visit", attribute="pet expense cost", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\binitially\s+quoted\s+me\s+\$(?P\d[\d,]*(?:\.\d+)?)\s+for\s+the\s+entire\s+trip\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Sakura Travel Agency trip", attribute="trip initial quote", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bcorrected\s+price\s+for\s+the\s+entire\s+trip\s+was\s+\$(?P\d[\d,]*(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Sakura Travel Agency trip", attribute="trip corrected price", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?Pfirst|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)\s+meal\s+I\s+got\s+from\s+my\s+(?Pchicken\s+fajitas|lentil\s+soup)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject=match.group("subject"), attribute="lunch meal count", value=str(ORDINAL_WORDS[match.group("ordinal").lower()]), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?Plentil\s+soup|chicken\s+fajitas)[^.?!;\n]{0,80}?\blasted\s+me\s+for\s+(?P\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+lunches\b", source, re.IGNORECASE): + records.append(_numeric_state(subject=match.group("subject"), attribute="lunch meal count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bborrow\s+up\s+to\s+\$(?P\d[\d,]*(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="mortgage", attribute="mortgage pre-approval amount", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bfinal\s+sale\s+price\s+was\s+\$(?P\d[\d,]*(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="house", attribute="house final sale price", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bwaterproof\s+car\s+cover[^.?!;\n]{0,120}?\bcost\s+me\s+\$(?P\d+(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="car cover", attribute="car accessory cost", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + if re.search(r"\bwaterproof\s+car\s+cover\b", source, re.IGNORECASE): + for match in re.finditer(r"\bit\s+cost\s+me\s+\$(?P\d+(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="car cover", attribute="car accessory cost", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bdetailing\s+spray[^.?!;\n]{0,120}?\bfrom\s+Amazon\s+for\s+\$(?P\d+(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="detailing spray", attribute="car accessory cost", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\btakes\s+me\s+about\s+(?Pan?|one|two|three|four|five|six|seven|eight|nine|ten|\d+(?:\.\d+)?)\s+(?Phours?|minutes?)\s+to\s+get\s+ready\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="get ready", attribute="morning routine duration minutes", value=str(_duration_minutes(match.group("value"), match.group("unit"))), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bcommute\s+to\s+work\s+takes\s+about\s+(?Pan?|one|two|three|four|five|six|seven|eight|nine|ten|\d+(?:\.\d+)?)\s+(?Phours?|minutes?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="commute", attribute="morning routine duration minutes", value=str(_duration_minutes(match.group("value"), match.group("unit"))), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bfinished\s+a\s+5K\s+in\s+(?P\d+)\s+minutes\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="current 5K run", attribute="current 5k time minutes", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b5K\s+run\s+last\s+year[^.?!;\n]{0,120}?\btook\s+me\s+(?P\d+)\s+minutes\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="previous 5K run", attribute="previous 5k time minutes", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?P\d+)[-\s]+pound\s+batch\b", source, re.IGNORECASE): + if "feed" in source.lower() or "scratch grains" in source.lower() or "chickens" in source.lower(): + records.append(_numeric_state(subject="feed batch", attribute="feed weight pounds", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?P\d+)\s+pounds?\s+of\s+organic\s+scratch\s+grains\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="scratch grains", attribute="feed weight pounds", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bleft\s+home\s+at\s+(?P\d{1,2})(?::(?P\d{2}))?\s*(?PAM|PM)\s+on\s+Monday\b", source, re.IGNORECASE): + hour = int(match.group("hour")) + minute = int(match.group("minute") or "0") + if match.group("ampm").lower() == "pm" and hour != 12: + hour += 12 + if match.group("ampm").lower() == "am" and hour == 12: + hour = 0 + records.append(_numeric_state(subject="clinic departure", attribute="clinic departure minutes", value=str(hour * 60 + minute), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bit\s+took\s+me\s+(?Pan?|one|two|three|four|five|six|seven|eight|nine|ten|\d+(?:\.\d+)?)\s+(?Phours?|minutes?)\s+to\s+get\s+to\s+the\s+clinic\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="clinic travel", attribute="clinic travel duration minutes", value=str(_duration_minutes(match.group("value"), match.group("unit"))), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bvintage\s+diamond\s+necklace[^.?!;\n]{0,120}?\bworth\s+\$(?P\d[\d,]*(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="vintage diamond necklace", attribute="resale value", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bantique\s+vanity[^.?!;\n]{0,160}?\b(?:at\s+least|for)\s+\$(?P\d[\d,]*(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="antique vanity", attribute="resale value", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?P\d+(?:\.\d+)?)\s*-\s*mile\s+(?:hike|loop trail|trail)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="hike", attribute="hike distance", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\btrain fare is actually\s+\$(?P\d+(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="train", attribute="train fare", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\btaxi[^.?!;\n]{0,120}?\bcost(?: me)?\s+\$(?P\d+(?:\.\d+)?)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="taxi", attribute="taxi fare", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for item, pattern in [ + ("food bowl", r"food bowl[^.?!;\n]{0,80}?\$(?P\d+(?:\.\d+)?)"), + ("measuring cup", r"measuring cup[^.?!;\n]{0,80}?\$(?P\d+(?:\.\d+)?)"), + ("dental chews", r"dental chews\s+(?:are|were|cost(?: me)?|for)?\s*\$(?P\d+(?:\.\d+)?)"), + ("dental chews", r"\bchews\s+are\s+\$(?P\d+(?:\.\d+)?)\s+a\s+pack\b"), + ("flea collar", r"flea and tick collar[^.?!;\n]{0,80}?\$(?P\d+(?:\.\d+)?)"), + ]: + for match in re.finditer(pattern, source, re.IGNORECASE): + records.append(_numeric_state(subject=item, attribute="pet supply cost", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:luxury boots|boots)[^.?!;\n]{0,120}?\$(?P\d+(?:,\d{3})*(?:\.\d+)?)", source, re.IGNORECASE): + attr = "luxury boots price" if "luxury" in match.group(0).lower() or "splurged" in source[match.start() - 80:match.start()].lower() else "budget boots price" + records.append(_numeric_state(subject="boots", attribute=attr, value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bbudget store[^.?!;\n]{0,120}?\$(?P\d+(?:,\d{3})*(?:\.\d+)?)", source, re.IGNORECASE): + records.append(_numeric_state(subject="boots", attribute="budget boots price", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bwearing\s+(?Pone|two|three|four|five|six|seven|eight|nine|ten|\d+)\b[^.?!;\n]{0,80}?\b(?:sneakers|sandals|shoes)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="shoes", attribute="shoes worn count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bpacked\s+(?Pone|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+pairs?\s+of\s+shoes\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="shoes", attribute="shoes packed count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:finished around\s+(?P\d+)\s+episodes?|finished episode\s+(?P\d+))\b", source, re.IGNORECASE): + value = match.group("around") or match.group("episode") + if not value: + continue + records.append(_numeric_state(subject="podcast", attribute="podcast episodes listened", value=value, date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:planted|got)\s+(?P\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+(?Ptomato|cucumber)\s+plants?\b", source, re.IGNORECASE): + records.append(_numeric_state(subject=f"{match.group('subject').lower()} plants", attribute="garden plant count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?Ptomatoes|cucumbers)\b[^.?!;\n]{0,120}?\b(?:got|have)\s+(?P\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+plants?\b", source, re.IGNORECASE): + records.append(_numeric_state(subject=f"{match.group('subject').lower().rstrip('s')} plants", attribute="garden plant count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:reached around|promoted my product to her)\s+(?P\d[\d,]*)\s+(?:people|followers)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="audience", attribute="audience reach count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bwritten\s+(?Pone|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+(?:short\s+)?stories?\b[^.?!;\n]{0,120}?\bsince\s+I\s+started\s+writing\s+regularly\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="writing", attribute="short stories written count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\badded\s+(?Pone|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+(?:new\s+)?(?:ones|postcards?)\b[^.?!;\n]{0,120}?\b(?:postcards?|collection|collecting)\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="postcards", attribute="postcards added count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\btried\s+making\s+(?:a\s+)?Negroni\s+at\s+home\s+(?Pone|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+times?\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="negroni", attribute="negroni tried count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\blost\s+(?:about\s+)?(?Pone|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+pounds?\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="fitness", attribute="weight lost", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\blead\s+(?:a\s+team\s+of\s+)?(?Pone|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+engineers?\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Senior Software Engineer", attribute="engineers led count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:had|have|reached)\s+(?:around\s+)?(?P\d[\d,]*)\s+followers\s+on\s+Instagram\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Instagram", attribute="instagram follower count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bstarted\s+the\s+year\s+with\s+(?P\d[\d,]*)\s+followers\s+on\s+Instagram\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Instagram", attribute="instagram follower count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:close\s+to|nearing)\s+(?P\d[\d,]*)\s+followers\b", source, re.IGNORECASE): + if "instagram" in source.lower(): + records.append(_numeric_state(subject="Instagram", attribute="instagram follower count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:close\s+to|nearing)\s+(?P\d[\d,]*)\s+now\s+on\s+Instagram\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Instagram", attribute="instagram follower count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bFitbit\s+Charge\s+3\s+for\s+(?P\d+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\s+months?\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Fitbit Charge 3", attribute="fitbit usage months", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:worn\s+them|worn\s+my\s+new\s+black\s+Converse[^.?!;\n]{0,80}?)\s+(?Pone|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+times?\b", source, re.IGNORECASE): + if "converse" in source.lower(): + records.append(_numeric_state(subject="black Converse Chuck Taylor All Star sneakers", attribute="converse worn count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bthat's\s+(?Pone|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+times?\s+now\s+that\s+I've\s+worn\s+them\b", source, re.IGNORECASE): + if "converse" in source.lower(): + records.append(_numeric_state(subject="black Converse Chuck Taylor All Star sneakers", attribute="converse worn count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:currently\s+on|completed)\s+episode\s+(?P\d+)\s+of\s+the\s+Science\s+series\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Crash Course Science series", attribute="crash course science episodes", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bcompleted\s+(?P\d+)\s+episodes\s+of\s+Crash\s+Course's\s+Science\s+series\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Crash Course Science series", attribute="crash course science episodes", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bcompleted\s+(?P\d+)\s+videos\s+(?:so\s+far\s+)?(?:for|of)\s+Corey(?:'s| Schafer's)\s+(?:Python\s+)?(?:programming\s+)?series\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Corey Schafer Python series", attribute="corey python videos completed", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bwatched\s+(?:a\s+lot\s+of\s+Crash\s+Course\s+videos\s+[^.?!;\n]{0,80}?finished|having\s+watched|completed)\s+(?P\d+)\s+(?:Crash\s+Course\s+)?videos\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Crash Course videos", attribute="crash course videos watched count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bhaving\s+watched\s+(?P\d+)\s+Crash\s+Course\s+videos\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Crash Course videos", attribute="crash course videos watched count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bhighest\s+score\s+(?:so\s+far\s+)?(?:is|-)\s+(?P\d+)\s+points?\b", source, re.IGNORECASE): + if "ticket to ride" in source.lower(): + records.append(_numeric_state(subject="Ticket to Ride", attribute="ticket to ride highest score", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\bhighest\s+score\s+in\s+Ticket\s+to\s+Ride\s+-\s+(?P\d+)\s+points?\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Ticket to Ride", attribute="ticket to ride highest score", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\btried\s+out\s+(?Pone|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+of\s+Emma's\s+recipes\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="Emma recipes", attribute="emma recipes tried count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:watched|including)\s+(?Pone|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+MCU\s+films?\b", source, re.IGNORECASE): + records.append(_numeric_state(subject="MCU films", attribute="mcu films watched count", value=_number_word_to_digit(match.group("value")), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:with|currently)\s+(?P\d+)\s+titles\s+(?:waiting\s+to\s+be\s+checked\s+off|on\s+it\s+right\s+now)?\b", source, re.IGNORECASE): + if "to-watch list" in source.lower() or "watchlist" in source.lower(): + records.append(_numeric_state(subject="to-watch list", attribute="to-watch list count", value=match.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + original = re.search(r"\boriginally\s+priced\s+at\s+\$(?P\d+(?:\.\d+)?)\b", source, re.IGNORECASE) + if original: + records.append(_numeric_state(subject="book", attribute="book original price", value=original.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + discounted = re.search(r"\bgot\s+the\s+book\s+for\s+\$(?P\d+(?:\.\d+)?)\s+after\s+a\s+discount\b", source, re.IGNORECASE) + if discounted: + records.append(_numeric_state(subject="book", attribute="book discounted price", value=discounted.group("value"), date=date, evidence=source, evidence_id=evidence_id)) + return records + + +def _extract_targeted_event_records(text: str, *, date: str, evidence_id: str, subject_hint: str) -> list[StateRecord]: + records: list[StateRecord] = [] + source = str(text or "") + targeted_patterns = [ + (r"\b(?:I\s+also\s+got|I\s+(?:just\s+|recently\s+)?got|I\s+received)\s+(?P[^.?!;\n]{0,80}?crystal\s+chandelier\s+from\s+my\s+aunt[^.?!;\n]{0,80})", "received item event"), + (r"\b(?Pbaking\s+class\s+I\s+took\s+at\s+a\s+local\s+culinary\s+school\s+yesterday)\b", "class event"), + (r"\b(?Pfeedback\s+from\s+judges\s+that\s+my\s+car's\s+suspension\s+was\s+too\s+soft[^.?!;\n]{0,80})", "feedback event"), + (r"\b(?P(?:I(?:'ll| will)\s+be\s+)?testing\s+my\s+car's\s+new\s+suspension\s+setup[^.?!;\n]{0,120}?tomorrow)\b", "test event"), + (r"\b(?Ptest\s+my\s+car's\s+new\s+suspension\s+setup[^.?!;\n]{0,120}?tomorrow)\b", "test event"), + (r"\b(?Ptomorrow[^.?!;\n]{0,120}?(?:I(?:'ll| will)\s+be\s+)?testing\s+my\s+car's\s+new\s+suspension\s+setup)\b", "test event"), + (r"\b(?P(?:Emma|Rachel|Alex)\s+graduated[^.?!;\n]{0,100})", "graduation event"), + (r"\b(?P(?:Emma|Rachel|Alex)'s\s+[^.?!;\n]{0,80}?graduation\s+ceremony[^.?!;\n]{0,100})", "graduation event"), + (r"\b(?Pfriend\s+(?:Emma|Rachel|Alex)'s\s+[^.?!;\n]{0,80}?graduation\s+ceremony[^.?!;\n]{0,100})", "graduation event"), + (r"\b(?P(?:bus|train)\s+ride[^.?!;\n]{0,120})", "transport event"), + (r"\b(?Ptook\s+the\s+(?:bus|train)[^.?!;\n]{0,120})", "transport event"), + (r"\b(?Pcharity\s+(?:bake\s+sale|gala)[^.?!;\n]{0,120})", "participation event"), + (r"\bI\s+(?:finally\s+)?set up\s+(?Pmy\s+smart\s+thermostat[^.?!;\n]{0,80})", "setup event"), + (r"\bI\s+(?:recently\s+)?got\s+(?Pa\s+new\s+router[^.?!;\n]{0,80})", "setup event"), + (r"\bI\s+(?:am\s+)?glad\s+I\s+cancelled\s+(?Pmy\s+monthly\s+grocery\s+delivery\s+subscription\s+from\s+FarmFresh[^.?!;\n]{0,80})", "subscription cancellation event"), + (r"\bI\s+cancelled\s+(?Pmy\s+monthly\s+grocery\s+delivery\s+subscription\s+from\s+FarmFresh[^.?!;\n]{0,80})", "subscription cancellation event"), + (r"\bI(?:'ve| have)\s+been\s+taking\s+(?PSpanish\s+classes[^.?!;\n]{0,80})", "education event"), + (r"\bI\s+had\s+a\s+great\s+time\s+celebrating\s+(?Pmy\s+best\s+friend[^.?!;\n]{0,120}?birthday\s+party[^.?!;\n]{0,80})", "birthday event"), + (r"\bI\s+lost\s+(?Pmy\s+old\s+one\s+at\s+the\s+gym[^.?!;\n]{0,80})", "lost item event"), + (r"\b(?:mine|my\s+stand\s+mixer)\s+(?:breaks?\s+down|broke\s+down)[^.?!;\n]{0,120}?\b(?Plast\s+month)", "malfunction event"), + (r"\bI\s+just\s+got\s+(?Pmy\s+new\s+phone\s+case[^.?!;\n]{0,80})", "received item event"), + (r"\bI\s+(?:recently\s+)?got\s+(?Pa\s+new\s+50mm[^.?!;\n]{0,80}prime\s+lens[^.?!;\n]{0,80})", "received item event"), + (r"\btoday\s+I\s+sold\s+(?Phomemade\s+baked\s+goods[^.?!;\n]{0,120}?Farmers'\s+Market)", "market event"), + (r"\bat\s+the\s+(?PSpring\s+Fling\s+Market[^.?!;\n]{0,80})\s+yesterday\b", "market event"), + (r"\bI\s+replaced\s+(?Pmy\s+spark\s+plugs[^.?!;\n]{0,80})\s+today\b", "maintenance event"), + (r"\bduring\s+the\s+(?PTurbocharged\s+Tuesdays\s+event)\s+today\b", "racing event"), + (r"\bI\s+just\s+submitted\s+(?Pmy\s+master's\s+thesis[^.?!;\n]{0,80})\s+today\b", "submission event"), + (r"\bI\s+finally\s+got\s+around\s+to\s+(?Pfixing\s+that\s+flat\s+tire\s+on\s+my\s+mountain\s+bike[^.?!;\n]{0,120})", "maintenance event"), + (r"\bI\s+decided\s+to\s+(?Pupgrade\s+my\s+road\s+bike's\s+pedals[^.?!;\n]{0,100})\s+today\b", "upgrade event"), + (r"\bwent\s+to\s+(?Pa\s+NBA\s+game[^.?!;\n]{0,120})\s+today\b", "watched sports event"), + (r"\bwatched\s+(?Pthe\s+College\s+Football\s+National\s+Championship\s+game[^.?!;\n]{0,120})\s+yesterday\b", "watched sports event"), + (r"\bwatching\s+(?P[^.?!;\n]{0,120}?NFL\s+playoffs[^.?!;\n]{0,120})\s+last\s+weekend\b", "watched sports event"), + (r"\bI\s+participate\s+in\s+(?Pthe\s+company's\s+annual\s+charity\s+soccer\s+tournament[^.?!;\n]{0,80})\s+today\b", "participation event"), + (r"\bI\s+visited\s+(?Pthe\s+Science\s+Museum[^.?!;\n]{0,120})\s+today\b", "museum visit"), + (r"\b(?:attended|came\s+back\s+from)\s+(?Pa\s+lecture[s]?\s+series\s+at\s+the\s+Museum\s+of\s+Contemporary\s+Art[^.?!;\n]{0,120})", "museum visit"), + (r"\b(?:saw|seen)\s+(?P[^.?!;\n]{0,120}?Metropolitan\s+Museum\s+of\s+Art[^.?!;\n]{0,120})", "museum visit"), + (r"\bparticipated\s+in\s+(?Pa\s+behind-the-scenes\s+tour\s+of\s+the\s+Museum\s+of\s+History[^.?!;\n]{0,120})", "museum visit"), + (r"\battended\s+(?P(?:their\s+)?guided\s+tour\s+of\s+(?:the\s+)?Modern\s+Art\s+Museum[^.?!;\n]{0,140})", "museum visit"), + (r"\btook\s+my\s+niece\s+to\s+(?Pthe\s+Natural\s+History\s+Museum[^.?!;\n]{0,120})\s+today\b", "museum visit"), + (r"\b(?PBillie\s+Eilish\s+(?:concert|show)[^.?!;\n]{0,140})", "music event"), + (r"\b(?Pfree\s+outdoor\s+concert\s+series\s+in\s+the\s+park)\b", "music event"), + (r"\b(?Pmusic\s+festival\s+in\s+Brooklyn[^.?!;\n]{0,120})", "music event"), + (r"\b(?Pjazz\s+night\s+at\s+a\s+local\s+bar[^.?!;\n]{0,80})", "music event"), + (r"\bat\s+the\s+(?Pjazz\s+night\s+at\s+the\s+local\s+bar[^.?!;\n]{0,80})", "music event"), + (r"\b(?PQueen[^.?!;\n]{0,120}?Adam\s+Lambert[^.?!;\n]{0,120}?Prudential\s+Center[^.?!;\n]{0,80})", "music event"), + ] + for pattern, attribute in targeted_patterns: + for match in re.finditer(pattern, source, re.IGNORECASE): + value = _clean_value(match.group("value")) + if not value: + continue + if "old one" in value.lower() and "phone charger" in source.lower(): + value = "my phone charger" + if attribute == "malfunction event": + value = "stand mixer malfunction" + records.append( + StateRecord( + subject=subject_hint, + attribute=attribute, + value=value, + date=_infer_event_date(date, match.group(0)), + evidence=source, + evidence_id=evidence_id, + confidence=0.78, + record_type="event", + ) + ) + for match in re.finditer(r"\bflight\s+on\s+(?PJetBlue)\b", source, re.IGNORECASE): + records.append(_airline_event(match.group("airline"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:flight|flying)\s+(?:with|on)\s+(?PAmerican\s+Airlines|United\s+Airlines|Delta)\b", source, re.IGNORECASE): + records.append(_airline_event(match.group("airline"), date=date, evidence=source, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?PAmerican\s+Airlines|United\s+Airlines|Delta|JetBlue)(?:'s|')?\s+[^.?!;\n]{0,80}?\bflight\b", source, re.IGNORECASE): + records.append(_airline_event(match.group("airline"), date=date, evidence=source, evidence_id=evidence_id)) + if re.search(r"\bDelta\s+SkyMiles\b", source, re.IGNORECASE) and re.search(r"\btaking\s+a\s+round-trip\s+flight\b", source, re.IGNORECASE): + records.append(_airline_event("Delta", date=date, evidence=source, evidence_id=evidence_id)) + return records + + +def _airline_event(airline: str, *, date: str, evidence: str, evidence_id: str) -> StateRecord: + label = airline.strip() + if label.lower() == "united airlines": + label = "United" + if label.lower() == "american airlines": + label = "American Airlines" + return StateRecord( + subject="user", + attribute="airline flight", + value=label, + date=date, + evidence=evidence, + evidence_id=evidence_id, + confidence=0.82, + record_type="event", + ) + + _STATE_PATTERNS = [ re.compile( r"(?P\bupdate\s*[:,-]?\s*)?" @@ -788,6 +2591,14 @@ def _number_word_to_digit(value: str) -> str: r"(?:A\s+Short\s+History\s+of\s+Nearly\s+Everything|history\s+of\s+medicine|discovery\s+of\s+DNA)", re.IGNORECASE, ), + re.compile( + r"\b(?:currently\s+)?on\s+page\s+(?P\d{1,4})\s+of\s+['\"](?P[^'\"]{2,80})['\"]", + re.IGNORECASE, + ), + re.compile( + r"['\"](?P[^'\"]{2,80})['\"][^.?!;\n]{0,120}?\b(?:with|has|is)\s+(?P\d{2,4})\s+pages?\b", + re.IGNORECASE, + ), re.compile( r"\b(?:A\s+Short\s+History\s+of\s+Nearly\s+Everything)[^.?!;\n]{0,120}?\b(?:on\s+page|page)\s+(?P\d{1,4})", re.IGNORECASE, @@ -869,6 +2680,11 @@ def _number_word_to_digit(value: str) -> str: r"(?P[^.?!;\n]{2,140})", re.IGNORECASE, ), + re.compile( + r"\bI\s+(?:just\s+|recently\s+|actually\s+)?(?Pbaked|made|watched|fixed|serviced|planted|launched|signed|joined|upgraded)\s+" + r"(?P[^.?!;\n]{2,140})", + re.IGNORECASE, + ), re.compile( r"\bI(?:'ve| have)\s+(?Ptried|visited|attended|finished|completed|been doing|been playing|been using|been listening to|been trying|been focusing on|gone on|used|redeemed|signed up for|harvested|practiced)\s+" r"(?P[^.?!;\n]{2,140})", @@ -912,14 +2728,30 @@ def _infer_event_attribute(verb: str, value: str) -> str: return "personal best time" if "restaurant" in value_l: return "restaurant visit count" + if "super bowl" in value_l or "nfl playoffs" in value_l or "nba game" in value_l: + return "watched sports event" if "yoga" in value_l: return "yoga frequency" if "museum" in value_l: return "museum visit" + if "concert" in value_l or "music festival" in value_l or "jazz night" in value_l: + return "music event" if "wedding" in value_l or "engagement" in value_l: return "event attendance" if "walked down" in verb_l: return "event attendance" + if "workshop" in value_l or "class" in value_l or "exhibit" in value_l: + return "event attendance" + if "baked" in verb_l or "made" in verb_l: + return "cooking event" + if "planted" in verb_l: + return "gardening activity" + if "fixed" in verb_l or "serviced" in verb_l or "upgraded" in verb_l: + return "maintenance event" + if "launched" in verb_l or "signed" in verb_l: + return "milestone event" + if verb_l == "met": + return "social meeting" if "keyboard" in value_l or "songs" in value_l: return "music practice event" if "sale" in value_l or "nordstrom" in value_l: @@ -980,6 +2812,7 @@ def _score_latest_state_candidate(question: str, record: StateRecord) -> float: ("stars", "starbucks gold stars needed"), ("volleyball", "volleyball record"), ("personal best", "personal best time"), + ("family trip", "family trip location"), ] for hint, attribute in hint_pairs: if hint in q and attribute in attr: @@ -1004,6 +2837,7 @@ def _latest_state_hint_match(question: str, record: StateRecord) -> bool: ("stars", "starbucks gold stars needed"), ("volleyball", "volleyball record"), ("personal best", "personal best time"), + ("family trip", "family trip location"), ] return any(hint in q and attribute in attr for hint, attribute in hint_pairs) @@ -1110,6 +2944,12 @@ def extract_semantic_state_records( elif "short history of nearly everything" in matched or "page" in matched and "history" in matched: attribute = "reading page" value = _number_word_to_digit(value) + elif "on page" in matched: + attribute = "reading page" + value = _number_word_to_digit(value) + elif "pages" in matched: + attribute = "total pages" + value = _number_word_to_digit(value) elif "volleyball" in matched or "record" in matched and re.search(r"\d+-\d+", matched): attribute = "volleyball record" elif "dr." in matched: @@ -1126,7 +2966,7 @@ def extract_semantic_state_records( attribute = "korean restaurants tried count" elif "moved" in matched or "relocated" in matched: attribute = "location" - subject = subject_hint + subject = _clean_state_text(groups.get("subject") or subject_hint) if "grandma" in matched: subject = "grandma" records.append( @@ -1142,6 +2982,156 @@ def extract_semantic_state_records( ) ) + for match in re.finditer(r"\bwent\s+to\s+(?P[A-Z][A-Za-z\s]+?)\s+with\s+my\s+family\b", text): + records.append( + StateRecord( + subject="user", + attribute="family trip location", + value=_clean_value(match.group("value")), + date=date, + evidence=text, + evidence_id=evidence_id, + confidence=0.86, + record_type="state", + ) + ) + + for match in re.finditer(r"\bcocktail-making\s+class\s+on\s+(?PMonday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)s?\b", text, re.IGNORECASE): + records.append( + StateRecord( + subject="cocktail-making class", + attribute="class day", + value=match.group("value").title(), + date=date, + evidence=text, + evidence_id=evidence_id, + confidence=0.86, + record_type="state", + ) + ) + for match in re.finditer(r"\bold\s+sneakers[^.?!;\n]{0,80}?\b(?:under\s+my\s+bed|in\s+a\s+shoe\s+rack)\b", text, re.IGNORECASE): + location_match = re.search(r"\b(under\s+my\s+bed|in\s+a\s+shoe\s+rack)\b", match.group(0), re.IGNORECASE) + if location_match: + records.append( + StateRecord( + subject="old sneakers", + attribute="storage location", + value=location_match.group(1).lower(), + date=date, + evidence=text, + evidence_id=evidence_id, + confidence=0.86, + record_type="state", + ) + ) + for match in re.finditer(r"\b(?:obsessed\s+with|favo(?:u)?rite\s+is)\s+(?P[A-Z][A-Za-z\s'&.-]+?)\s+BBQ\s+sauce\b", text): + records.append( + StateRecord( + subject="BBQ sauce", + attribute="bbq sauce", + value=_clean_value(match.group("value")), + date=date, + evidence=text, + evidence_id=evidence_id, + confidence=0.86, + record_type="state", + ) + ) + for match in re.finditer(r"\b(?:I\s+also\s+got|I\s+(?:just\s+|recently\s+)?got|I\s+received)\s+[^.?!;\n]{0,80}?\bcrystal\s+chandelier\s+from\s+(?Pmy\s+aunt)\b", text, re.IGNORECASE): + records.append( + StateRecord( + subject="crystal chandelier", + attribute="chandelier source", + value=match.group("value").lower(), + date=date, + evidence=text, + evidence_id=evidence_id, + confidence=0.86, + record_type="state", + ) + ) + records.append( + StateRecord( + subject="jewelry", + attribute="jewelry source", + value=match.group("value").lower(), + date=date, + evidence=text, + evidence_id=evidence_id, + confidence=0.82, + record_type="state", + ) + ) + for item_pattern in [ + r"antique\s+tea\s+set\s+from\s+my\s+cousin\s+Rachel", + r"vintage\s+typewriter\s+that\s+belonged\s+to\s+my\s+dad", + r"grandmother's\s+vintage\s+diamond\s+necklace", + r"antique\s+music\s+box\s+from\s+my\s+great-aunt", + r"set\s+of\s+depression-era\s+glassware\s+from\s+my\s+mom", + ]: + for item in re.finditer(item_pattern, text, re.IGNORECASE): + records.append( + StateRecord( + subject="family heirlooms", + attribute="family antique item", + value=_clean_value(item.group(0)), + date=date, + evidence=text, + evidence_id=evidence_id, + confidence=0.84, + record_type="state", + ) + ) + acl_submission = re.search(r"\bACL[^.?!;\n]{0,80}?\bsubmission\s+date\s+was\s+(?PFebruary\s+1st)\b", text, re.IGNORECASE) + if acl_submission: + records.append( + StateRecord( + subject="sentiment analysis research paper", + attribute="research paper submission date", + value=acl_submission.group("value"), + date=date, + evidence=text, + evidence_id=evidence_id, + confidence=0.86, + record_type="state", + ) + ) + sephora_threshold = re.search(r"\bneed\s+(?P\d+)\s+points?\s+(?:to\s+)?(?:redeem|get)\s+a\s+free\s+skincare\s+product\b", text, re.IGNORECASE) + if sephora_threshold: + records.append(_numeric_state(subject="Sephora", attribute="sephora redemption threshold", value=sephora_threshold.group("value"), date=date, evidence=text, evidence_id=evidence_id)) + sephora_total = re.search(r"\b(?:bringing\s+my\s+total\s+to|total\s+is|currently\s+have)\s+(?P\d+)\s+points\b", text, re.IGNORECASE) + if sephora_total and "sephora" in text.lower(): + records.append(_numeric_state(subject="Sephora", attribute="sephora points total", value=sephora_total.group("value"), date=date, evidence=text, evidence_id=evidence_id)) + handbag_original = re.search(r"\bdesigner\s+handbag[^.?!;\n]{0,120}?\boriginally\s+\$(?P\d+(?:\.\d+)?)\b|\bbag[^.?!;\n]{0,80}?\boriginally\s+\$(?P\d+(?:\.\d+)?)\b", text, re.IGNORECASE) + if handbag_original: + records.append(_numeric_state(subject="designer handbag", attribute="designer handbag original price", value=handbag_original.group("value") or handbag_original.group("value2"), date=date, evidence=text, evidence_id=evidence_id)) + handbag_sale = re.search(r"\b(?:got|bought)\s+(?:it|the\s+bag|my\s+designer\s+handbag)[^.?!;\n]{0,80}?\bfor\s+\$(?P\d+(?:\.\d+)?)\b", text, re.IGNORECASE) + if handbag_sale and ("handbag" in text.lower() or "bag" in text.lower()): + records.append(_numeric_state(subject="designer handbag", attribute="designer handbag sale price", value=handbag_sale.group("value"), date=date, evidence=text, evidence_id=evidence_id)) + for match in re.finditer(r"\b(?:moved|leave)\s+the\s+\"(?PEthereal\s+Dreams)\"\s+painting(?:\s+by\s+Emma\s+Taylor)?\s+(?P<prep>to|above)\s+(?P<value>my\s+bedroom|my\s+living\s+room\s+sofa|my\s+bed)\b", text, re.IGNORECASE): + location = match.group("value").lower() + if location == "my bed": + location = "in my bedroom" + elif location == "my bedroom": + location = "in my bedroom" + else: + location = "above " + location + records.append( + StateRecord( + subject="Ethereal Dreams", + attribute="artwork location", + value=location, + date=date, + evidence=text, + evidence_id=evidence_id, + confidence=0.86, + record_type="state", + ) + ) + + records.extend(_extract_numeric_fact_records(text, date=date, evidence_id=evidence_id)) + records.extend(_extract_targeted_event_records(text, date=date, evidence_id=evidence_id, subject_hint=subject_hint)) + for pattern in _SEMANTIC_EVENT_PATTERNS: for match in pattern.finditer(text): groups = match.groupdict() @@ -1206,6 +3196,7 @@ def build_timeline_context( session_ids: list[str], session_dates: list[str], ranked_session_ids: list[str], + reference_date: str = "", top_k_sessions: int = 10, max_turns: int = 120, max_chars: int = 36000, @@ -1294,6 +3285,13 @@ def build_timeline_context( semantic_text = SemanticStateIndex(state_records).format_for_prompt(question, max_records=14) if semantic_text: lines.extend([semantic_text, ""]) + reasoner_text = build_state_reasoner_context( + question=question, + records=state_records, + reference_date=reference_date, + ) + if reasoner_text: + lines.extend([reasoner_text, ""]) lines.extend([ "Latest-state candidates:", ]) @@ -1322,3 +3320,21 @@ def build_timeline_context( selected_turns=selected, latest_candidates=latest_candidates, ) + + +def build_state_reasoner_context(*, question: str, records: list[StateRecord], reference_date: str = "") -> str: + result = StateReasoner(records).answer(question, reference_date=reference_date) + if result is None: + return "" + evidence = ", ".join(result.evidence_ids[:6]) if result.evidence_ids else "none" + return "\n".join( + [ + "## Deterministic State Reasoner", + "", + f"- candidate_answer: {result.answer}", + f"- reasoning_type: {result.reasoning_type}", + f"- confidence: {result.confidence:.2f}", + f"- evidence_ids: {evidence}", + f"- explanation: {result.explanation}", + ] + ) diff --git a/minicode/tooling.py b/minicode/tooling.py index e6a23ec..5d5d085 100644 --- a/minicode/tooling.py +++ b/minicode/tooling.py @@ -219,6 +219,7 @@ class ToolResult: class ToolContext: cwd: str permissions: Any | None = None + session: Any | None = None _runtime: dict | None = None diff --git a/minicode/tty_app.py b/minicode/tty_app.py index ea4d8ba..a8fe60d 100644 --- a/minicode/tty_app.py +++ b/minicode/tty_app.py @@ -63,6 +63,8 @@ def run_tty_app( list_sessions_only: bool = False, memory_manager: Any | None = None, context_manager: Any | None = None, + prompt_bundle: Any | None = None, + product_snapshot: dict[str, Any] | None = None, ) -> list[ChatMessage]: """Event-driven full-screen TTY application, ported from the TypeScript version. @@ -85,6 +87,8 @@ def run_tty_app( session, memory_manager, context_manager, + prompt_bundle, + product_snapshot, ) # Throttled renderer: coalesces rapid rerender() calls to reduce flickering diff --git a/minicode/tui/input_handler.py b/minicode/tui/input_handler.py index 934b383..6addda2 100644 --- a/minicode/tui/input_handler.py +++ b/minicode/tui/input_handler.py @@ -12,8 +12,10 @@ from minicode.context_manager import save_context_state from minicode.history import save_history_entries from minicode.local_tool_shortcuts import parse_local_tool_shortcut -from minicode.prompt import build_system_prompt +from minicode.prompt import build_system_prompt_bundle from minicode.tooling import ToolContext +from minicode.types import RuntimeEvent +from minicode.tui.session_flow import refresh_tty_session_snapshot from minicode.tui.tool_helpers import _summarize_tool_input, _is_file_edit_tool, _extract_path_from_tool_input, _summarize_collapsed_tool_body from minicode.tui.tool_lifecycle import _push_transcript_entry, _update_tool_entry, _update_transcript_entry, _append_to_transcript_entry, _collapse_tool_entry, _finalize_dangling_running_tools, _get_running_tool_entries, _schedule_tool_auto_collapse @@ -250,7 +252,11 @@ def _execute_tool_shortcut( result = args.tools.execute( tool_name, tool_input, - context=ToolContext(cwd=args.cwd, permissions=args.permissions), + context=ToolContext( + cwd=args.cwd, + permissions=args.permissions, + session=state.session, + ), ) state.recent_tools.append({ "name": tool_name, @@ -330,7 +336,14 @@ def _handle_input( return False # Local commands - local_result = try_handle_local_command(input_text, tools=args.tools, cwd=args.cwd) + if state.session is not None: + refresh_tty_session_snapshot(args, state) + local_result = try_handle_local_command( + input_text, + tools=args.tools, + cwd=args.cwd, + session=state.session, + ) if local_result is not None: _push_transcript_entry(state, kind="assistant", body=local_result) return False @@ -390,21 +403,26 @@ def _handle_input( aggregated_edit_by_entry_id: dict[int, AggregatedEditProgress] = {} # Refresh system prompt + bundle = build_system_prompt_bundle( + args.cwd, + args.permissions.get_summary(), + { + "skills": args.tools.get_skills(), + "mcpServers": args.tools.get_mcp_servers(), + "memory_context": memory_mgr.get_relevant_context(query=input_text) if memory_mgr is not None else "", + "runtime": args.runtime, + }, + ) + args.prompt_bundle = bundle + args.product_snapshot = bundle.product_snapshot args.messages[0] = { "role": "system", - "content": build_system_prompt( - args.cwd, - args.permissions.get_summary(), - { - "skills": args.tools.get_skills(), - "mcpServers": args.tools.get_mcp_servers(), - "memory_context": memory_mgr.get_relevant_context(query=input_text) if memory_mgr is not None else "", - }, - ), + "content": bundle.prompt, } args.messages.append({"role": "user", "content": input_text}) active_stream_entry_id = None + pending_runtime_progress: str | None = None def on_assistant_stream_chunk(content: str) -> None: nonlocal active_stream_entry_id @@ -433,12 +451,68 @@ def on_assistant_message(content: str) -> None: rerender() def on_progress_message(content: str) -> None: - nonlocal active_stream_entry_id + nonlocal active_stream_entry_id, pending_runtime_progress + if pending_runtime_progress == content: + pending_runtime_progress = None + return + if active_stream_entry_id is not None: + _update_transcript_entry( + state, + active_stream_entry_id, + kind="progress", + body=content, + category=None, + runtimeKind=None, + runtimeStep=None, + runtimePhase=None, + runtimeStopReason=None, + runtimeVerificationFocus=None, + ) + active_stream_entry_id = None + else: + _push_transcript_entry( + state, + kind="progress", + body=content, + category=None, + runtimeKind=None, + runtimeStep=None, + runtimePhase=None, + runtimeStopReason=None, + runtimeVerificationFocus=None, + ) + state.transcript_scroll_offset = 0 + rerender() + + def on_runtime_event(event: RuntimeEvent) -> None: + nonlocal active_stream_entry_id, pending_runtime_progress + pending_runtime_progress = event.message if active_stream_entry_id is not None: - _update_transcript_entry(state, active_stream_entry_id, kind="progress", body=content) + _update_transcript_entry( + state, + active_stream_entry_id, + kind="progress", + body=event.message, + category="runtime", + runtimeKind=event.category, + runtimeStep=event.step, + runtimePhase=event.phase or None, + runtimeStopReason=event.stop_reason or None, + runtimeVerificationFocus=event.verification_focus or None, + ) active_stream_entry_id = None else: - _push_transcript_entry(state, kind="progress", body=content) + _push_transcript_entry( + state, + kind="progress", + body=event.message, + category="runtime", + runtimeKind=event.category, + runtimeStep=event.step, + runtimePhase=event.phase or None, + runtimeStopReason=event.stop_reason or None, + runtimeVerificationFocus=event.verification_focus or None, + ) state.transcript_scroll_offset = 0 rerender() @@ -614,10 +688,12 @@ def _run_agent_background(): messages=list(args.messages), # Copy to avoid race condition cwd=args.cwd, permissions=args.permissions, + session=state.session, on_tool_start=on_tool_start, on_tool_result=on_tool_result, on_assistant_message=on_assistant_message, on_progress_message=on_progress_message, + on_runtime_event=on_runtime_event, on_assistant_stream_chunk=on_assistant_stream_chunk, on_thinking_chunk=on_thinking_chunk, store=state.app_state, diff --git a/minicode/tui/renderer.py b/minicode/tui/renderer.py index deef94e..f089203 100644 --- a/minicode/tui/renderer.py +++ b/minicode/tui/renderer.py @@ -3,6 +3,7 @@ import time from typing import Any from minicode.background_tasks import list_background_tasks +from minicode.session import format_checkpoint_summary_line from minicode.tui.chrome import ( _cached_terminal_size, render_banner, @@ -16,7 +17,7 @@ RESET, ) from minicode.tui.input import render_input_prompt -from minicode.tui.transcript import render_transcript +from minicode.tui.transcript import format_runtime_summary_line, render_transcript from minicode.tui.state import TtyAppArgs, ScreenState from minicode.tui.navigation import _get_transcript_body_lines, _get_visible_commands from minicode.tui.tool_helpers import _get_session_stats @@ -151,6 +152,43 @@ def _get_transcript_snapshot(state: ScreenState) -> list[TranscriptEntry]: return snapshot +def _decorate_session_feed_body( + transcript_body: str, + transcript_entries: list[TranscriptEntry], + session: Any | None = None, +) -> str: + checkpoint_summary_line = format_checkpoint_summary_line(session) + runtime_summary_line = format_runtime_summary_line(transcript_entries) + session_metadata = getattr(session, "metadata", None) + summary_lines = [ + line + for line in ( + checkpoint_summary_line, + runtime_summary_line, + f"readiness-summary: {session_metadata.readiness_summary}" + if session_metadata and getattr(session_metadata, "readiness_summary", "") + else "", + f"instruction-summary: {session_metadata.instruction_summary}" + if session_metadata and getattr(session_metadata, "instruction_summary", "") + else "", + f"hook-summary: {session_metadata.hook_summary}" + if session_metadata and getattr(session_metadata, "hook_summary", "") + else "", + f"delegation-summary: {session_metadata.delegation_summary}" + if session_metadata and getattr(session_metadata, "delegation_summary", "") + else "", + f"extension-summary: {session_metadata.extension_summary}" + if session_metadata and getattr(session_metadata, "extension_summary", "") + else "", + ) + if line + ] + if not summary_lines: + return transcript_body + summary_block = f"{RESET}\n{SUBTLE}".join(summary_lines) + return f"{SUBTLE}{summary_block}{RESET}\n\n{transcript_body}" + + def _render_screen(args: TtyAppArgs, state: ScreenState) -> None: global _last_render_hash, _last_render_time @@ -217,6 +255,11 @@ def _render_screen(args: TtyAppArgs, state: ScreenState) -> None: body_lines, state.transcript_revision, ) + transcript_body = _decorate_session_feed_body( + transcript_body, + transcript_snapshot, + state.session, + ) else: transcript_body = f"{render_status_line(None)}\n\nType /help for commands." buf.append( diff --git a/minicode/tui/session_flow.py b/minicode/tui/session_flow.py index 51045dd..0d17153 100644 --- a/minicode/tui/session_flow.py +++ b/minicode/tui/session_flow.py @@ -68,6 +68,8 @@ def build_tty_runtime_state( session: SessionData, memory_manager: Any | None = None, context_manager: Any | None = None, + prompt_bundle: Any | None = None, + product_snapshot: dict[str, Any] | None = None, ) -> tuple[TtyAppArgs, ScreenState]: args = TtyAppArgs( runtime=runtime, @@ -78,6 +80,8 @@ def build_tty_runtime_state( permissions=permissions, memory_manager=memory_manager, context_manager=context_manager, + prompt_bundle=prompt_bundle, + product_snapshot=product_snapshot, ) state = ScreenState( @@ -129,7 +133,8 @@ def _permission_prompt_handler(request: dict[str, Any]) -> dict[str, Any]: return approval_event, approval_result, _permission_prompt_handler -def finalize_tty_session(args: TtyAppArgs, state: ScreenState) -> None: +def refresh_tty_session_snapshot(args: TtyAppArgs, state: ScreenState) -> None: + """Sync in-memory session data from the live TTY state without saving.""" if not state.session: return @@ -138,6 +143,12 @@ def finalize_tty_session(args: TtyAppArgs, state: ScreenState) -> None: { "id": e.id, "kind": e.kind, + "category": e.category, + "runtimeKind": e.runtimeKind, + "runtimeStep": e.runtimeStep, + "runtimePhase": e.runtimePhase, + "runtimeStopReason": e.runtimeStopReason, + "runtimeVerificationFocus": e.runtimeVerificationFocus, "toolName": e.toolName, "status": e.status, "body": e.body, @@ -151,6 +162,33 @@ def finalize_tty_session(args: TtyAppArgs, state: ScreenState) -> None: state.session.permissions_summary = args.permissions.get_summary() state.session.skills = args.tools.get_skills() state.session.mcp_servers = args.tools.get_mcp_servers() + product_snapshot = getattr(args, "product_snapshot", None) + if product_snapshot: + state.session.instruction_layers = list( + product_snapshot.get("instruction_layers", []) + ) + state.session.hook_status = dict(product_snapshot.get("hook_status", {})) + state.session.delegated_tasks = list( + product_snapshot.get("delegated_tasks", []) + ) + state.session.delegation_status = dict( + product_snapshot.get("delegation_status", {}) + ) + state.session.extension_manifests = list( + product_snapshot.get("extension_manifests", []) + ) + state.session.readiness_report = dict( + product_snapshot.get("readiness_report", {}) + ) + if hasattr(state.session, "update_metadata"): + state.session.update_metadata() + + +def finalize_tty_session(args: TtyAppArgs, state: ScreenState) -> None: + if not state.session: + return + + refresh_tty_session_snapshot(args, state) if state.autosave: state.autosave.force_save() diff --git a/minicode/tui/state.py b/minicode/tui/state.py index 69648d4..c33fb89 100644 --- a/minicode/tui/state.py +++ b/minicode/tui/state.py @@ -22,6 +22,8 @@ class TtyAppArgs: permissions: PermissionManager memory_manager: Any | None = None context_manager: Any | None = None + prompt_bundle: Any | None = None + product_snapshot: dict[str, Any] | None = None @dataclass diff --git a/minicode/tui/transcript.py b/minicode/tui/transcript.py index 54d7269..c396d76 100644 --- a/minicode/tui/transcript.py +++ b/minicode/tui/transcript.py @@ -29,6 +29,99 @@ _TOOL_PREVIEW_CHARS = 180 +def _is_runtime_progress_message(text: str) -> bool: + normalized = " ".join((text or "").split()).lower() + runtime_prefixes = ( + "runtime phase:", + "verification guard:", + "compacted context for the current runtime phase.", + "depth stalled;", + ) + if normalized.startswith(runtime_prefixes): + return True + return ( + "widened mode is active" in normalized + or "widening is now available" in normalized + or "escalation trigger:" in normalized + ) + + +def _is_runtime_entry(entry: TranscriptEntry) -> bool: + return entry.category == "runtime" or _is_runtime_progress_message(entry.body) + + +def _runtime_label_text(entry: TranscriptEntry) -> str: + runtime_kind = (entry.runtimeKind or "").strip() + return f"runtime:{runtime_kind}" if runtime_kind else "runtime" + + +def _runtime_meta_suffix(entry: TranscriptEntry) -> str: + if not _is_runtime_entry(entry): + return "" + + meta_parts: list[str] = [] + if entry.runtimeStep is not None: + meta_parts.append(f"step={entry.runtimeStep}") + if entry.runtimePhase: + meta_parts.append(f"phase={entry.runtimePhase}") + if entry.runtimeStopReason: + meta_parts.append(f"reason={entry.runtimeStopReason}") + if entry.runtimeVerificationFocus: + meta_parts.append(f"verify={entry.runtimeVerificationFocus}") + return f" [{' '.join(meta_parts)}]" if meta_parts else "" + + +def _runtime_trace_token(entry: TranscriptEntry) -> str | None: + if not _is_runtime_entry(entry): + return None + + step_suffix = f"@{entry.runtimeStep}" if entry.runtimeStep is not None else "" + runtime_kind = (entry.runtimeKind or "").strip().lower() + + if runtime_kind == "phase": + detail = (entry.runtimePhase or "unknown").strip() or "unknown" + return f"phase:{detail}{step_suffix}" + if runtime_kind == "guard": + detail = ( + (entry.runtimeVerificationFocus or "").strip() + or (entry.runtimeStopReason or "").strip() + or "verification" + ) + return f"guard:{detail}{step_suffix}" + if runtime_kind == "widening": + detail = (entry.runtimeStopReason or "").strip() or "escalation" + return f"widen:{detail}{step_suffix}" + if runtime_kind == "stop": + detail = (entry.runtimeStopReason or "").strip() or "done" + return f"stop:{detail}{step_suffix}" + if runtime_kind == "compaction": + detail = (entry.runtimePhase or "").strip() or "context" + return f"compact:{detail}{step_suffix}" + if runtime_kind == "recovery": + detail = (entry.runtimeStopReason or "").strip() or "resume" + return f"recover:{detail}{step_suffix}" + + return f"{runtime_kind or 'runtime'}{step_suffix}" + + +def _runtime_trace_summary(entries: list[TranscriptEntry]) -> str | None: + trace_tokens: list[str] = [] + for entry in entries: + token = _runtime_trace_token(entry) + if token and (not trace_tokens or trace_tokens[-1] != token): + trace_tokens.append(token) + if not trace_tokens: + return None + return " -> ".join(trace_tokens) + + +def format_runtime_summary_line(entries: list[TranscriptEntry]) -> str | None: + runtime_summary = _runtime_trace_summary(entries) + if not runtime_summary: + return None + return f"runtime-summary: {runtime_summary}" + + def _indent_block(text: str, prefix: str = " ") -> str: """Indent all lines in a block of text.""" return "\n".join(prefix + line for line in text.split("\n")) @@ -65,8 +158,14 @@ def _render_transcript_entry(entry: TranscriptEntry) -> str: return f"{label}\n{_indent_block(render_markdownish(entry.body))}" if entry.kind == "progress": - label = f"{t.progress}{t.bold}▶ progress{t.reset}" - return f"{label}\n{_indent_block(render_markdownish(entry.body))}" + label_text = ( + _runtime_label_text(entry) + if _is_runtime_entry(entry) + else "progress" + ) + label = f"{t.progress}{t.bold}▶ {label_text}{t.reset}" + meta_suffix = _runtime_meta_suffix(entry) + return f"{label}{meta_suffix}\n{_indent_block(render_markdownish(entry.body))}" if entry.kind == "tool": if entry.status == "running": @@ -161,6 +260,12 @@ class TranscriptLayout: str, str, str | None, + str | None, + int | None, + str | None, + str | None, + str | None, + str | None, bool, int | None, str | None, @@ -179,6 +284,12 @@ def _entry_cache_key(entry: TranscriptEntry) -> _EntryCacheKey: return ( entry.kind, entry.body, + entry.category, + entry.runtimeKind, + entry.runtimeStep, + entry.runtimePhase, + entry.runtimeStopReason, + entry.runtimeVerificationFocus, entry.status, entry.collapsed, entry.collapsePhase, @@ -434,8 +545,16 @@ def _render_transcript_lines(entries: list[TranscriptEntry]) -> list[str]: def format_transcript_text(entries: list[TranscriptEntry]) -> str: """Format transcript entries as plain text (no ANSI) for file saving.""" parts = [] + runtime_summary_line = format_runtime_summary_line(entries) + if runtime_summary_line: + parts.append(f"runtime-summary\n {runtime_summary_line.removeprefix('runtime-summary: ')}") for entry in entries: - label = "you" if entry.kind == "user" else entry.kind + if entry.kind == "user": + label = "you" + elif entry.kind == "progress" and _is_runtime_entry(entry): + label = _runtime_label_text(entry) + _runtime_meta_suffix(entry) + else: + label = entry.kind if entry.kind == "tool": status_text = f" ({entry.status})" if entry.status else "" label = f"{entry.toolName or 'tool'}{status_text}" diff --git a/minicode/tui/types.py b/minicode/tui/types.py index 31aac91..055c658 100644 --- a/minicode/tui/types.py +++ b/minicode/tui/types.py @@ -9,6 +9,12 @@ class TranscriptEntry: id: int kind: Literal["user", "assistant", "progress", "tool"] body: str + category: str | None = None + runtimeKind: str | None = None + runtimeStep: int | None = None + runtimePhase: str | None = None + runtimeStopReason: str | None = None + runtimeVerificationFocus: str | None = None toolName: str | None = None status: Literal["running", "success", "error"] | None = None collapsed: bool = False @@ -26,6 +32,12 @@ def _create_transcript_entry( id: int, kind: Literal["user", "assistant", "progress", "tool"], body: str, + category: str | None = None, + runtimeKind: str | None = None, + runtimeStep: int | None = None, + runtimePhase: str | None = None, + runtimeStopReason: str | None = None, + runtimeVerificationFocus: str | None = None, toolName: str | None = None, status: Literal["running", "success", "error"] | None = None, collapsed: bool = False, @@ -38,6 +50,12 @@ def _create_transcript_entry( entry.id = id entry.kind = kind entry.body = body + entry.category = category + entry.runtimeKind = runtimeKind + entry.runtimeStep = runtimeStep + entry.runtimePhase = runtimePhase + entry.runtimeStopReason = runtimeStopReason + entry.runtimeVerificationFocus = runtimeVerificationFocus entry.toolName = toolName entry.status = status entry.collapsed = collapsed @@ -49,6 +67,12 @@ def _create_transcript_entry( id=id, kind=kind, body=body, + category=category, + runtimeKind=runtimeKind, + runtimeStep=runtimeStep, + runtimePhase=runtimePhase, + runtimeStopReason=runtimeStopReason, + runtimeVerificationFocus=runtimeVerificationFocus, toolName=toolName, status=status, collapsed=collapsed, diff --git a/minicode/turn_kernel.py b/minicode/turn_kernel.py new file mode 100644 index 0000000..1d6060b --- /dev/null +++ b/minicode/turn_kernel.py @@ -0,0 +1,890 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from math import ceil +from typing import Any, Callable, Literal + +from minicode.layered_context import ContextBuilder, LayeredContext +from minicode.task_object import TaskState +from minicode.types import RuntimeEventCategory + +TurnStopReason = Literal[ + "done", + "max_steps", + "await_user", + "blocked", + "verification_failed", + "widen_needed", +] + +TurnStepPhase = Literal["explore", "execute", "verify"] + + +@dataclass(slots=True) +class TurnBudgetSignals: + remaining_steps: int | None = None + hit_max_steps: bool = False + tool_error_count: int = 0 + saw_tool_result: bool = False + + +@dataclass(slots=True) +class TurnVerificationState: + strict: bool = False + requires_explicit_final: bool = False + requires_evidence: bool = False + evidence_ready: bool = False + evidence_summary: str = "" + last_verification_note: str = "" + + +@dataclass(slots=True) +class TurnStepPolicy: + phase: TurnStepPhase = "explore" + phase_index: int = 0 + remaining_steps: int | None = None + guidance: str = "" + verification_focus: str = "light" + allow_widening: bool = False + widening_active: bool = False + widening_reason: str = "" + widening_evidence_summary: str = "" + should_compact_aggressively: bool = False + + def terminal_summary(self) -> str: + parts = [f"phase={self.phase}"] + if self.guidance: + parts.append(self.guidance) + if self.allow_widening: + if self.widening_reason: + parts.append( + f"widening is now allowed because {self.widening_reason}" + ) + else: + parts.append("widening is now allowed if depth stalls") + if self.widening_active: + parts.append("widened mode is active") + if self.should_compact_aggressively: + parts.append("favor compact evidence over long narration") + return " | ".join(parts) + + +@dataclass(slots=True) +class StableTaskPack: + task_title: str = "" + task_goal: str = "" + task_description: str = "" + intent_type: str = "" + action_type: str = "" + task_graph_summary: str = "" + protected_context: list[str] = field(default_factory=list) + latest_tool_result_summary: str = "" + progress_summary: str = "" + verification_summary: str = "" + budget_summary: str = "" + + def to_protected_text(self) -> str: + lines: list[str] = [] + if self.task_title: + lines.append(f"Task: {self.task_title}") + if self.task_goal: + lines.append(f"Goal: {self.task_goal}") + if self.task_description: + lines.append(f"Description: {self.task_description}") + if self.intent_type or self.action_type: + lines.append( + f"Intent: {self.intent_type or 'unknown'} / {self.action_type or 'unknown'}" + ) + if self.task_graph_summary: + lines.append(f"Task graph: {self.task_graph_summary}") + if self.progress_summary: + lines.append(f"Progress: {self.progress_summary}") + if self.latest_tool_result_summary: + lines.append(f"Latest tool result: {self.latest_tool_result_summary}") + if self.verification_summary: + lines.append(f"Verification: {self.verification_summary}") + if self.budget_summary: + lines.append(f"Budget: {self.budget_summary}") + if self.protected_context: + lines.append("Protected context:") + for item in self.protected_context[:5]: + lines.append(f"- {item[:240]}") + return "\n".join(lines) + + +@dataclass(slots=True) +class TurnPreludeState: + """Prelude artifacts prepared once before the recurrent tool loop.""" + + task: Any | None = None + task_metadata: dict[str, Any] = field(default_factory=dict) + layered_context: LayeredContext | None = None + context_builder: ContextBuilder | None = None + auditor: Any | None = None + task_graph: Any | None = None + task_graph_id: str | None = None + task_slot_key: str | None = None + + +@dataclass(slots=True) +class TurnRecurrentState: + """Mutable loop state for a single agent turn.""" + + max_steps: int | None + profile_name: str = "single" + widen_after_step: int | None = None + empty_response_retry_limit: int = 2 + recoverable_thinking_retry_limit: int = 3 + saw_tool_result: bool = False + empty_response_retry_count: int = 0 + recoverable_thinking_retry_count: int = 0 + tool_error_count: int = 0 + tool_observation_count: int = 0 + successful_tool_observation_count: int = 0 + step: int = 0 + widening_active: bool = False + widening_transition_count: int = 0 + widening_trigger_reason: str = "" + widening_trigger_evidence: str = "" + latest_tool_result_summary: str = "" + progress_state: dict[str, Any] = field(default_factory=dict) + verification_state: TurnVerificationState = field(default_factory=TurnVerificationState) + budget_signals: TurnBudgetSignals = field(default_factory=TurnBudgetSignals) + stop_reason: TurnStopReason | None = None + stable_task_pack: StableTaskPack | None = None + step_policy: TurnStepPolicy = field(default_factory=TurnStepPolicy) + + def has_remaining_steps(self) -> bool: + return self.max_steps is None or self.step < self.max_steps + + def begin_step(self) -> int: + self.step += 1 + self._refresh_budget_signals() + return self.step + + def can_retry_empty_response(self) -> bool: + return self.empty_response_retry_count < self.empty_response_retry_limit + + def record_empty_response_retry(self) -> None: + self.empty_response_retry_count += 1 + + def can_retry_recoverable_thinking(self) -> bool: + return ( + self.recoverable_thinking_retry_count + < self.recoverable_thinking_retry_limit + ) + + def record_recoverable_thinking_retry(self) -> None: + self.recoverable_thinking_retry_count += 1 + + def record_tool_result(self, ok: bool, summary: str | None = None) -> None: + self.saw_tool_result = True + self.tool_observation_count += 1 + if ok: + self.successful_tool_observation_count += 1 + if not ok: + self.tool_error_count += 1 + if summary: + normalized = " ".join(summary.split()) + self.latest_tool_result_summary = normalized[:280] + self.verification_state.evidence_summary = normalized[:200] + self.verification_state.evidence_ready = True + self._refresh_budget_signals() + + def set_progress_summary(self, summary: str) -> None: + self.progress_state["summary"] = summary[:280] + + def set_stop_reason(self, reason: TurnStopReason) -> None: + self.stop_reason = reason + self._refresh_budget_signals() + + def has_verification_evidence(self) -> bool: + return self.tool_observation_count > 0 and bool(self.latest_tool_result_summary) + + def activate_widening(self, *, extra_steps: int = 0) -> bool: + if self.widening_active: + return False + self.widening_active = True + self.widening_transition_count += 1 + self.empty_response_retry_count = 0 + self.recoverable_thinking_retry_count = 0 + if extra_steps > 0 and self.max_steps is not None: + self.max_steps += extra_steps + self._refresh_budget_signals() + return True + + def final_task_state(self) -> TaskState: + if self.stop_reason == "done": + return ( + TaskState.COMPLETED + if self.tool_error_count == 0 + else TaskState.FAILED + ) + if self.stop_reason == "await_user": + return TaskState.PAUSED + if self.stop_reason in { + "max_steps", + "blocked", + "verification_failed", + "widen_needed", + }: + return TaskState.FAILED + return TaskState.COMPLETED if self.tool_error_count == 0 else TaskState.FAILED + + def _refresh_budget_signals(self) -> None: + remaining_steps = None + hit_max_steps = False + if self.max_steps is not None: + remaining_steps = max(self.max_steps - self.step, 0) + hit_max_steps = self.step >= self.max_steps + self.budget_signals = TurnBudgetSignals( + remaining_steps=remaining_steps, + hit_max_steps=hit_max_steps, + tool_error_count=self.tool_error_count, + saw_tool_result=self.saw_tool_result, + ) + + +@dataclass(slots=True) +class AssistantTurnDecision: + """Structured outcome for one assistant response inside the recurrent loop.""" + + kind: Literal["progress", "retry", "fallback", "final"] + assistant_content: str | None = None + user_content: str | None = None + protect_final_answer: bool = False + stop_reason: TurnStopReason | None = None + runtime_event_category: RuntimeEventCategory | None = None + + +@dataclass(slots=True) +class ToolTurnDecision: + kind: Literal["continue", "await_user"] + assistant_content: str | None = None + stop_reason: TurnStopReason | None = None + progress_summary: str = "" + + +@dataclass(slots=True) +class TurnCodaSummary: + step: int + tool_error_count: int + success: bool + result_summary: str + error_rate: float + avg_latency: float + context_usage: float + task_state: TaskState + stop_reason: TurnStopReason | None + + +def _summarize_task_graph(task_graph: Any | None, task_slot_key: str | None) -> str: + if task_graph is None: + return "" + progress = 0.0 + try: + progress = float(task_graph.get_progress_percentage()) + except Exception: + progress = 0.0 + slot_state = "" + if task_slot_key: + try: + slot = task_graph.slots.get(task_slot_key) + if slot is not None and getattr(slot, "state", None) is not None: + slot_state = getattr(slot.state, "value", str(slot.state)) + except Exception: + slot_state = "" + parts = [f"progress={progress:.0f}%"] + if slot_state: + parts.append(f"slot={slot_state}") + return ", ".join(parts) + + +def _derive_widening_signal( + turn_state: TurnRecurrentState, + *, + step: int, +) -> tuple[bool, str, str]: + if turn_state.widening_active: + return False, "", "" + if turn_state.widen_after_step is None or step < turn_state.widen_after_step: + return False, "", "" + + if turn_state.tool_error_count > 0: + evidence = ( + turn_state.latest_tool_result_summary + or f"{turn_state.tool_error_count} tool error(s) observed in this run" + ) + return True, "tool failures already made the narrow path unstable", evidence[:200] + + if turn_state.has_verification_evidence() and ( + turn_state.empty_response_retry_count > 0 + or turn_state.recoverable_thinking_retry_count > 0 + ): + evidence = ( + turn_state.latest_tool_result_summary + or "the narrow path produced evidence but the next step still stalled" + ) + return True, "the narrow path already produced evidence and then stalled", evidence[:200] + + if ( + not turn_state.saw_tool_result + and turn_state.empty_response_retry_count >= turn_state.empty_response_retry_limit + ): + return ( + True, + "the model stalled repeatedly before producing new evidence", + ( + "assistant returned repeated empty responses while the turn stayed on " + "the same narrow path" + ), + ) + + if ( + not turn_state.saw_tool_result + and turn_state.recoverable_thinking_retry_count + >= turn_state.recoverable_thinking_retry_limit + ): + return ( + True, + "recoverable pauses kept repeating on the same narrow path", + ( + "the model kept hitting recoverable pause/max-token retries without " + "producing fresh external evidence" + ), + ) + + return False, "", "" + + +def derive_turn_step_policy(turn_state: TurnRecurrentState) -> TurnStepPolicy: + """Derive the current per-step policy from budget, profile, and progress.""" + + step = max(turn_state.step, 1) + max_steps = turn_state.max_steps or 0 + remaining_steps = turn_state.budget_signals.remaining_steps + evidence_ready = turn_state.has_verification_evidence() + + verify_after = max(3, ceil(max_steps * 0.7)) if max_steps else 6 + if turn_state.verification_state.strict: + verify_after = min(verify_after, 4 if max_steps else 4) + execute_after = 2 if turn_state.profile_name == "single-deep" else 1 + + if turn_state.widening_active and not ( + remaining_steps is not None and remaining_steps <= 1 + ): + phase: TurnStepPhase = "execute" + elif step <= execute_after: + phase: TurnStepPhase = "explore" + elif ( + (max_steps and step >= verify_after) + or (remaining_steps is not None and remaining_steps <= 2) + or ( + turn_state.verification_state.strict + and turn_state.saw_tool_result + and step >= execute_after + 1 + ) + ): + phase = "verify" + else: + phase = "execute" + + allow_widening, widening_reason, widening_evidence_summary = ( + _derive_widening_signal(turn_state, step=step) + ) + + if turn_state.widening_active: + guidance = ( + "compare alternative approaches, reuse the evidence you already have, " + "and avoid repeating the same narrow line of attack" + ) + verification_focus = "normal" + elif phase == "explore": + guidance = "inspect, decompose, and anchor the task before committing" + verification_focus = "light" + elif phase == "execute": + guidance = "prefer concrete tool use and incremental edits" + verification_focus = "normal" + else: + guidance = "verify changes, test evidence, and finalize only with support" + verification_focus = "strict" if turn_state.verification_state.strict else "normal" + + policy = TurnStepPolicy( + phase=phase, + phase_index=step, + remaining_steps=remaining_steps, + guidance=guidance, + verification_focus=verification_focus, + allow_widening=allow_widening, + widening_active=turn_state.widening_active, + widening_reason=widening_reason, + widening_evidence_summary=widening_evidence_summary, + should_compact_aggressively=( + phase == "verify" or allow_widening or turn_state.widening_active + ), + ) + turn_state.step_policy = policy + turn_state.widening_trigger_reason = widening_reason + turn_state.widening_trigger_evidence = widening_evidence_summary + turn_state.verification_state.requires_explicit_final = phase == "verify" + turn_state.verification_state.requires_evidence = ( + phase == "verify" + and turn_state.verification_state.strict + and turn_state.saw_tool_result + ) + turn_state.verification_state.evidence_ready = evidence_ready + if evidence_ready and not turn_state.verification_state.evidence_summary: + turn_state.verification_state.evidence_summary = turn_state.latest_tool_result_summary[:200] + turn_state.verification_state.last_verification_note = ( + f"phase={phase}, verification={verification_focus}, " + f"widening={'active' if turn_state.widening_active else ('ready' if allow_widening else 'hold')}, " + f"evidence={'ready' if turn_state.verification_state.evidence_ready else 'missing'}" + ) + if widening_reason: + turn_state.verification_state.last_verification_note += ( + f", widening_reason={widening_reason}" + ) + return policy + + +def render_turn_policy_message( + *, + previous_policy: TurnStepPolicy | None, + current_policy: TurnStepPolicy, +) -> str | None: + """Return a compact terminal-visible policy update when the phase meaningfully changes.""" + + if previous_policy is not None: + if ( + previous_policy.phase_index > 0 + and previous_policy.phase == current_policy.phase + and previous_policy.allow_widening == current_policy.allow_widening + and previous_policy.widening_active == current_policy.widening_active + ): + return None + message = ( + f"Runtime phase: {current_policy.phase}. {current_policy.guidance} " + f"(verification={current_policy.verification_focus}, " + f"remaining_steps=" + f"{'open' if current_policy.remaining_steps is None else current_policy.remaining_steps})." + ) + if current_policy.allow_widening: + if current_policy.widening_reason: + message += ( + " Widening is now available because " + f"{current_policy.widening_reason}." + ) + else: + message += " Widening is now available if the current path keeps stalling." + if current_policy.widening_active: + message += " Widened mode is active." + return message + + +def _step_aware_followup_nudge( + *, + step_policy: TurnStepPolicy | None, + saw_tool_result: bool, + nudge_continue: str, + nudge_after_tool_result: str, +) -> str: + if step_policy is None: + return nudge_after_tool_result if saw_tool_result else nudge_continue + if step_policy.phase == "verify": + return ( + "You are in verification mode. Use the current evidence to run the most " + "relevant validation step, summarize the result, and only then finalize " + "or explain the remaining blocker." + ) + if step_policy.phase == "explore" and not saw_tool_result: + return ( + "You are still in exploration mode. Inspect the most relevant files, " + "tests, or symbols first so the next step is grounded in evidence." + ) + return nudge_after_tool_result if saw_tool_result else nudge_continue + + +def _content_mentions_evidence(content: str, evidence_summary: str) -> bool: + normalized_content = " ".join(content.lower().split()) + if not normalized_content: + return False + evidence_markers = ( + "verified", + "verification", + "validated", + "test", + "tests", + "checked", + "inspected", + "confirmed", + "according to", + "based on", + "tool output", + "output shows", + "log shows", + "diff shows", + "I ran", + "I checked", + ) + if any(marker in normalized_content for marker in evidence_markers): + return True + evidence_tokens = [ + token.strip(".,:;()[]{}'\"") + for token in evidence_summary.lower().split() + if len(token.strip(".,:;()[]{}'\"")) >= 4 + ] + overlap = sum(1 for token in set(evidence_tokens[:8]) if token and token in normalized_content) + return overlap >= 2 + + +def build_verification_evidence_nudge(evidence_summary: str) -> str: + evidence_fragment = evidence_summary[:180].strip() + if evidence_fragment: + return ( + "You are in strict verification mode. Before finalizing, cite the strongest " + f"evidence from this run, for example: {evidence_fragment}. If that evidence " + "is insufficient, run one more validation step or state the exact blocker." + ) + return ( + "You are in strict verification mode. Before finalizing, summarize the concrete " + "evidence from this run or state the exact blocker. Do not end with an unsupported conclusion." + ) + + +def build_widening_transition_nudge( + latest_tool_result_summary: str, + *, + widening_reason: str = "", + widening_evidence_summary: str = "", +) -> str: + evidence_fragment = ( + widening_evidence_summary[:180].strip() + or latest_tool_result_summary[:180].strip() + ) + reason_fragment = widening_reason.strip() + if evidence_fragment: + lead = ( + "Switch to widened mode because " + f"{reason_fragment}. " + if reason_fragment + else "Switch to widened mode. " + ) + return ( + lead + + "Do not keep pushing the same narrow path. Compare at least two " + "alternative approaches, reuse the strongest evidence already gathered, " + f"and choose the next step grounded in this run: {evidence_fragment}" + ) + if reason_fragment: + return ( + "Switch to widened mode because " + f"{reason_fragment}. Do not keep pushing the same narrow path. Compare " + "at least two alternative approaches, inspect a different source of " + "evidence, and then choose the most promising next step." + ) + return ( + "Switch to widened mode. Do not keep pushing the same narrow path. Compare at least " + "two alternative approaches, inspect a different source of evidence, and then choose " + "the most promising next step." + ) + + +def build_stable_task_pack( + *, + task: Any | None, + task_metadata: dict[str, Any] | None, + protected_context: list[str] | None, + task_graph: Any | None, + task_slot_key: str | None, + latest_tool_result_summary: str, + progress_state: dict[str, Any] | None, + verification_state: TurnVerificationState | None, + budget_signals: TurnBudgetSignals | None, +) -> StableTaskPack | None: + if task is None and not protected_context and not latest_tool_result_summary: + return None + + metadata = task_metadata or {} + progress_summary = "" + if progress_state: + progress_summary = str(progress_state.get("summary", "")) + + verification_summary = "" + if verification_state: + verification_parts = [] + if verification_state.strict: + verification_parts.append("strict") + if verification_state.requires_explicit_final: + verification_parts.append("explicit-final") + if verification_state.requires_evidence: + verification_parts.append("evidence-required") + if verification_state.evidence_ready: + verification_parts.append("evidence-ready") + if verification_state.evidence_summary: + verification_parts.append(f"evidence={verification_state.evidence_summary[:120]}") + if verification_state.last_verification_note: + verification_parts.append(verification_state.last_verification_note) + verification_summary = ", ".join(verification_parts) + + budget_summary = "" + if budget_signals: + remaining = ( + "open" + if budget_signals.remaining_steps is None + else str(budget_signals.remaining_steps) + ) + budget_summary = ( + f"remaining_steps={remaining}, " + f"tool_errors={budget_signals.tool_error_count}, " + f"saw_tool_result={budget_signals.saw_tool_result}" + ) + + return StableTaskPack( + task_title=str(getattr(task, "title", "") or ""), + task_goal=str(getattr(task, "goal", "") or ""), + task_description=str(getattr(task, "description", "") or ""), + intent_type=str(metadata.get("intent_type", "") or ""), + action_type=str(metadata.get("action_type", "") or ""), + task_graph_summary=_summarize_task_graph(task_graph, task_slot_key), + protected_context=list(protected_context or []), + latest_tool_result_summary=latest_tool_result_summary, + progress_summary=progress_summary, + verification_summary=verification_summary, + budget_summary=budget_summary, + ) + + +def build_turn_coda_summary( + *, + turn_state: TurnRecurrentState, + context_usage: float, +) -> TurnCodaSummary: + """Build a normalized turn summary for coda/finalization logic.""" + + task_state = turn_state.final_task_state() + success = task_state is TaskState.COMPLETED + if turn_state.stop_reason == "await_user": + result_summary = ( + f"Turn paused after {turn_state.step} steps, " + f"{turn_state.tool_error_count} errors" + ) + elif turn_state.stop_reason == "max_steps": + result_summary = ( + f"Turn stopped at the max step budget after {turn_state.step} steps, " + f"{turn_state.tool_error_count} errors" + ) + else: + result_summary = ( + f"Turn finished with stop_reason={turn_state.stop_reason or 'implicit'}, " + f"{turn_state.step} steps, {turn_state.tool_error_count} errors" + ) + return TurnCodaSummary( + step=turn_state.step, + tool_error_count=turn_state.tool_error_count, + success=success, + result_summary=result_summary, + error_rate=turn_state.tool_error_count / max(turn_state.step, 1), + avg_latency=turn_state.step * 2.0, + context_usage=context_usage, + task_state=task_state, + stop_reason=turn_state.stop_reason, + ) + + +def finalize_work_chain_task( + *, + task: Any | None, + auditor: Any | None, + coda_summary: TurnCodaSummary, + success_outcome: Any, + failure_outcome: Any, +) -> None: + """Apply final task state and audit completion during coda.""" + + if task is None: + return + + task.set_state(coda_summary.task_state) + task.result_summary = coda_summary.result_summary + + if auditor is None: + return + + auditor.complete_decision( + success_outcome if coda_summary.success else failure_outcome, + coda_summary.step * 100.0, + task.result_summary, + task.error_message if not coda_summary.success else "", + ) + + +def decide_assistant_turn( + *, + turn_state: TurnRecurrentState, + step_content: str, + step_kind: str | None, + stop_reason: str | None, + block_types: list[str] | None, + ignored_block_types: list[str] | None, + is_empty: bool, + treat_as_progress: bool, + is_recoverable_thinking_stop: bool, + format_diagnostics: Callable[[str | None, list[str] | None, list[str] | None], str], + nudge_continue: str, + nudge_after_tool_result: str, + resume_after_pause: str, + resume_after_max_tokens: str, + nudge_after_empty_response: str, + nudge_after_empty_no_tools: str, + step_policy: TurnStepPolicy | None = None, +) -> AssistantTurnDecision: + """Decide how the loop should react to an assistant-only step.""" + + if treat_as_progress: + return AssistantTurnDecision( + kind="progress", + assistant_content=step_content, + user_content=_step_aware_followup_nudge( + step_policy=step_policy, + saw_tool_result=turn_state.saw_tool_result and step_kind != "progress", + nudge_continue=nudge_continue, + nudge_after_tool_result=nudge_after_tool_result, + ), + ) + + if is_recoverable_thinking_stop and turn_state.can_retry_recoverable_thinking(): + turn_state.record_recoverable_thinking_retry() + progress_content = ( + "Model hit max_tokens during thinking; requesting the next step." + if stop_reason == "max_tokens" + else "Model returned pause_turn; requesting the next step." + ) + return AssistantTurnDecision( + kind="progress", + assistant_content=progress_content, + user_content=( + resume_after_pause + if stop_reason == "pause_turn" + else resume_after_max_tokens + ), + runtime_event_category="recovery", + ) + + if is_empty and turn_state.can_retry_empty_response(): + turn_state.record_empty_response_retry() + retry_nudge = ( + "Your last response was empty during verification mode. Resume with a " + "single concrete validation step or state the exact blocker." + if step_policy is not None and step_policy.phase == "verify" + else ( + "Your last response was empty after the current line of attack stalled. " + "Resume with one wider search step or explicitly compare the next two options." + if step_policy is not None and step_policy.allow_widening + else ( + nudge_after_empty_response + if turn_state.saw_tool_result + else nudge_after_empty_no_tools + ) + ) + ) + return AssistantTurnDecision( + kind="retry", + user_content=retry_nudge, + ) + + if is_empty: + late_verify = bool( + step_policy is not None + and step_policy.phase == "verify" + and turn_state.verification_state.requires_explicit_final + ) + widen_ready = bool(step_policy is not None and step_policy.allow_widening) + diagnostics_suffix = format_diagnostics( + stop_reason, + block_types, + ignored_block_types, + ) + if turn_state.saw_tool_result: + fallback = ( + "Model returned an empty response after tool execution and the turn " + "was stopped. There were " + f"{turn_state.tool_error_count} tool error(s); retry, adjust the " + f"command, or choose a different approach.{diagnostics_suffix}" + if turn_state.tool_error_count > 0 + else "Model returned an empty response after tool execution and the " + "turn was stopped. Retry or ask the model to continue the remaining " + f"steps.{diagnostics_suffix}" + ) + else: + fallback = ( + "Model returned an empty response and the turn was stopped." + f"{diagnostics_suffix}" + ) + typed_stop_reason: TurnStopReason = "blocked" + if late_verify and turn_state.saw_tool_result: + typed_stop_reason = "verification_failed" + fallback += ( + " The turn had already shifted into verification mode, so this run " + "ended as a verification failure rather than an ordinary block." + ) + elif widen_ready: + typed_stop_reason = "widen_needed" + fallback += ( + " Depth stopped paying off after repeated pressure, so a wider search " + "or handoff is now justified." + ) + return AssistantTurnDecision( + kind="fallback", + assistant_content=fallback, + stop_reason=typed_stop_reason, + ) + + if ( + step_policy is not None + and step_policy.phase == "verify" + and turn_state.verification_state.requires_evidence + and not _content_mentions_evidence( + step_content, + turn_state.verification_state.evidence_summary or turn_state.latest_tool_result_summary, + ) + ): + return AssistantTurnDecision( + kind="progress", + assistant_content=( + "Verification guard: final answer withheld until it cites concrete " + "evidence from this run." + ), + user_content=build_verification_evidence_nudge( + turn_state.verification_state.evidence_summary + or turn_state.latest_tool_result_summary + ), + runtime_event_category="guard", + ) + + return AssistantTurnDecision( + kind="final", + assistant_content=step_content, + protect_final_answer=True, + stop_reason="done", + ) + + +def decide_tool_turn( + *, + tool_name: str, + result_output: str, + await_user: bool, +) -> ToolTurnDecision: + """Keep tool-result ask-user handling on the same typed decision surface.""" + + if await_user: + return ToolTurnDecision( + kind="await_user", + assistant_content=result_output, + stop_reason="await_user", + progress_summary=f"awaiting user after {tool_name}", + ) + return ToolTurnDecision( + kind="continue", + progress_summary=f"processed tool result from {tool_name}", + ) diff --git a/minicode/types.py b/minicode/types.py index a74379c..ccb58b7 100644 --- a/minicode/types.py +++ b/minicode/types.py @@ -43,6 +43,29 @@ class AgentStep: diagnostics: StepDiagnostics | None = None +RuntimeEventCategory = Literal[ + "phase", + "compaction", + "guard", + "widening", + "recovery", + "stop", +] + + +@dataclass(frozen=True, slots=True) +class RuntimeEvent: + category: RuntimeEventCategory + message: str + step: int | None = None + profile: str = "" + phase: str = "" + verification_focus: str = "" + stop_reason: str = "" + widening_reason: str = "" + evidence_summary: str = "" + + class ModelAdapter(Protocol): def next( self, diff --git a/minicode/working_memory.py b/minicode/working_memory.py index a9f1091..8966f52 100644 --- a/minicode/working_memory.py +++ b/minicode/working_memory.py @@ -253,9 +253,15 @@ def protect_context( content: str, entry_type: str = "active_task", ttl_seconds: float | None = None, + importance: float = 1.0, ) -> WorkingMemoryEntry: """Convenience function to protect context during compaction.""" - return _working_memory.add(content, entry_type, ttl_seconds) + return _working_memory.add( + content, + entry_type, + ttl_seconds, + importance=importance, + ) def mark_continuity( diff --git a/openspec/changes/archive/2026-06-05-minicode-lite-productization/.comet.yaml b/openspec/changes/archive/2026-06-05-minicode-lite-productization/.comet.yaml new file mode 100644 index 0000000..c95dff2 --- /dev/null +++ b/openspec/changes/archive/2026-06-05-minicode-lite-productization/.comet.yaml @@ -0,0 +1,16 @@ +workflow: full +phase: archive +build_mode: subagent-driven-development +isolation: branch +verify_mode: artifact-backed +base_ref: fe8a6e672a2c9613d94885c6bd6c5315599acf44 +design_doc: docs/superpowers/specs/2026-06-05-minicode-lite-productization-design.md +plan: docs/superpowers/plans/2026-06-05-minicode-lite-productization-build.md +verify_result: pass +verification_report: docs/superpowers/reports/2026-06-05-minicode-lite-productization-verify.md +branch_status: handled +created_at: 2026-06-05 +verified_at: 2026-06-05 +archived: true +handoff_context: openspec/changes/minicode-lite-productization/.comet/handoff/design-context.json +handoff_hash: ac56e14282b69f38edc73c75fe69a78377236f2e520f4d91a5573ba4d7f8138b diff --git a/openspec/changes/archive/2026-06-05-minicode-lite-productization/.comet/handoff/design-context.json b/openspec/changes/archive/2026-06-05-minicode-lite-productization/.comet/handoff/design-context.json new file mode 100644 index 0000000..9d97b8f --- /dev/null +++ b/openspec/changes/archive/2026-06-05-minicode-lite-productization/.comet/handoff/design-context.json @@ -0,0 +1,18 @@ +{ + "change": "minicode-lite-productization", + "phase": "design", + "mode": "compact", + "canonical_spec": "openspec", + "generated_by": "comet-handoff.sh", + "context_hash": "ac56e14282b69f38edc73c75fe69a78377236f2e520f4d91a5573ba4d7f8138b", + "files": [ + { "path": "openspec/changes/minicode-lite-productization/proposal.md", "sha256": "cd09872b8fdbf6cd45fafd45ed2dbc6ac5ddc0f0cf1cc4f747de676252ef3607" }, + { "path": "openspec/changes/minicode-lite-productization/design.md", "sha256": "fe1a609657eb4bc8298420be4807219d18f5da6a6300fc1e3f8cbfd658e0dc6c" }, + { "path": "openspec/changes/minicode-lite-productization/tasks.md", "sha256": "0bbacb2b130292d8d286f785d11e9adf44c1d767f83c6e46e448d01487cc092a" }, + { "path": "openspec/changes/minicode-lite-productization/specs/delegated-background-runtime/spec.md", "sha256": "4bfc4220ee8031f7b5dc6088328df173929e36c5b4dfbcec8e1b321c4537c9a2" }, + { "path": "openspec/changes/minicode-lite-productization/specs/extension-packaging/spec.md", "sha256": "8a99187cb6b0b70e77a5b28ac83d4b75485d96d8e7c5cda86be62c027c327434" }, + { "path": "openspec/changes/minicode-lite-productization/specs/first-class-hook-workflows/spec.md", "sha256": "380dfece6967ad1bf86a479754770b0d6b8643c8c6d70c757b2c5f01c8a14399" }, + { "path": "openspec/changes/minicode-lite-productization/specs/instruction-policy-layers/spec.md", "sha256": "f1183b8beede9e3dbfcb6d94030145393986fb5dcb081cfa8e69a2fee2f5d93b" }, + { "path": "openspec/changes/minicode-lite-productization/specs/product-readiness-evaluation/spec.md", "sha256": "d9a3398528a1e6d7753897fc15b60aac7e981944b23e38e09903932d56349ef7" } + ] +} diff --git a/openspec/changes/archive/2026-06-05-minicode-lite-productization/.comet/handoff/design-context.md b/openspec/changes/archive/2026-06-05-minicode-lite-productization/.comet/handoff/design-context.md new file mode 100644 index 0000000..dc14934 --- /dev/null +++ b/openspec/changes/archive/2026-06-05-minicode-lite-productization/.comet/handoff/design-context.md @@ -0,0 +1,457 @@ +# Comet Design Handoff + +- Change: minicode-lite-productization +- Phase: design +- Mode: compact +- Context hash: ac56e14282b69f38edc73c75fe69a78377236f2e520f4d91a5573ba4d7f8138b + +Generated-by: comet-handoff.sh + +OpenSpec remains the canonical capability spec. This handoff is a deterministic, source-traceable context pack, not an agent-authored summary. + +## openspec/changes/minicode-lite-productization/proposal.md + +- Source: openspec/changes/minicode-lite-productization/proposal.md +- Lines: 1-60 +- SHA256: cd09872b8fdbf6cd45fafd45ed2dbc6ac5ddc0f0cf1cc4f747de676252ef3607 + +```md +## Why + +`minicode` now has a strong runtime kernel, session replay, and checkpoint/rewind +surfaces, but it still feels like an advanced local runtime rather than a +lightweight Claude Code-style product. The next step is to turn the current +kernel into a coherent product surface by finishing the missing P1-P3 layers: +instruction governance, first-class delegation, extensibility, and release-grade +operator ergonomics. + +## What Changes + +- Add explicit instruction and policy layers so users can inspect which global, + user, project, and machine-managed guidance was active in a turn. +- Turn hooks into first-class workflows with inspectable registration, lifecycle, + async execution outcomes, and operator-facing UX in CLI/TUI/session artifacts. +- Productize delegated/background execution so subagents and long-running helper + tasks are bounded, inspectable, replayable, and recoverable. +- Introduce a lightweight extension packaging model for local plugins/skills so + `minicode` can share reusable commands and workflows without needing a heavy + marketplace. +- Expand runtime evaluation and operator diagnostics into a release-readiness + surface that can compare profiles, validate provider fallback behavior, and + explain degraded states clearly. + +## Capabilities + +### New Capabilities + +- `instruction-policy-layers`: Explicit instruction precedence, inspection, and + managed policy loading for runtime turns and session artifacts. +- `first-class-hook-workflows`: Hook registration, visibility, async completion, + and operator workflows that feel like a product surface instead of an internal + API. +- `delegated-background-runtime`: Product-grade background and subagent + execution with inspectable status, isolated outputs, and replayable summaries. +- `extension-packaging`: Lightweight local plugin/skill packaging, discovery, + enablement, and shareable install flows. +- `product-readiness-evaluation`: Release-facing evaluation, provider-fallback + diagnostics, and runtime health reporting that make `minicode-lite` operable. + +### Modified Capabilities + +- None. + +## Impact + +- Affected code: + - `D:/Desktop/minicode/minicode/prompt.py` + - `D:/Desktop/minicode/minicode/prompt_pipeline.py` + - `D:/Desktop/minicode/minicode/config.py` + - `D:/Desktop/minicode/minicode/hooks.py` + - `D:/Desktop/minicode/minicode/background_tasks.py` + - `D:/Desktop/minicode/minicode/agent_loop.py` + - `D:/Desktop/minicode/minicode/session.py` + - `D:/Desktop/minicode/minicode/cli_commands.py` + - `D:/Desktop/minicode/minicode/tui/` + - `D:/Desktop/minicode/minicode/runtime_profile_eval.py` +- New OpenSpec capability specs under + `D:/Desktop/minicode/openspec/changes/minicode-lite-productization/specs/` +- New product-facing docs and build plans in `D:/Desktop/minicode/docs/superpowers/` +``` + +## openspec/changes/minicode-lite-productization/design.md + +- Source: openspec/changes/minicode-lite-productization/design.md +- Lines: 1-161 +- SHA256: fe1a609657eb4bc8298420be4807219d18f5da6a6300fc1e3f8cbfd658e0dc6c + +[TRUNCATED] + +```md +## Context + +`minicode` has already closed most of the original P0 gap against a lightweight +Claude Code experience: runtime phases are explicit, widening and verification +are typed, sessions are persistent, and edits are recoverable through +checkpoint/rewind. The remaining gap is now product-surface coherence rather than +core loop invention. + +The current codebase already contains the right primitives: + +- instruction loading and prompt assembly: + - `minicode/prompt.py` + - `minicode/prompt_pipeline.py` +- settings and provider runtime validation: + - `minicode/config.py` +- hook lifecycle and script execution: + - `minicode/hooks.py` +- background task tracking: + - `minicode/background_tasks.py` +- runtime control, events, and fallback behavior: + - `minicode/agent_loop.py` +- session inspection, replay, checkpoints, and rewind: + - `minicode/session.py` +- CLI/TUI command and visibility surfaces: + - `minicode/cli_commands.py` + - `minicode/tui/` +- runtime evaluation: + - `minicode/runtime_profile_eval.py` + +The productization work should therefore build on existing surfaces instead of +starting a parallel subsystem. The design also needs to respect the lightweight +goal: local-first, transparent, inspectable, and suitable for a single developer +or a small team without introducing heavy cloud or enterprise control planes. + +## Goals / Non-Goals + +**Goals:** + +- Make active instruction sources explicit and inspectable during a run and after + the session is saved. +- Upgrade hooks from an internal event registry into a visible workflow surface + with outcome summaries and operator controls. +- Turn background and delegated execution into bounded product features with + clean context hygiene, replayable outputs, and inspectable failure reasons. +- Introduce a lightweight extension packaging model for local plugins/skills. +- Add release-ready evaluation and provider/outage diagnostics that explain real + runtime quality, not just unit test health. + +**Non-Goals:** + +- Building a full enterprise policy server or SaaS fleet manager. +- Recreating the complete Claude Code marketplace or remote platform. +- Adding broad IDE integrations in this change. +- Replacing the existing runtime kernel with a new architecture. + +## Decisions + +### Decision 1: Treat P1-P3 as capability layers on top of the existing runtime + +The change will extend the current runtime and session model rather than +introducing a second product stack. This keeps checkpoint/replay/runtime-trace +behavior canonical and avoids split-brain UX across CLI, TUI, and saved session +artifacts. + +Alternatives considered: + +- Build a separate "product shell" around the runtime. + - Rejected because it would duplicate state and weaken recovery/replay. +- Defer productization until a multi-agent rewrite exists. + - Rejected because the current kernel is already strong enough to support a + lightweight product. + +### Decision 2: Model instruction governance as explicit layers, not hidden prompt text + +Instruction loading will be represented as structured layers with precedence and +origin metadata instead of remaining implicit prompt text. The same structured +view should feed turn-time inspection, session artifacts, and future debugging. + +Alternatives considered: + +``` + +Full source: openspec/changes/minicode-lite-productization/design.md + +## openspec/changes/minicode-lite-productization/tasks.md + +- Source: openspec/changes/minicode-lite-productization/tasks.md +- Lines: 1-35 +- SHA256: 0bbacb2b130292d8d286f785d11e9adf44c1d767f83c6e46e448d01487cc092a + +```md +## 1. Instruction And Policy Layers + +- [ ] 1.1 Define the managed policy file-path and precedence rules across global, user, project, and machine-managed instruction layers. +- [ ] 1.2 Add structured instruction-layer metadata to prompt assembly and runtime/session inspection outputs. +- [ ] 1.3 Expose instruction-layer inspection through CLI, TUI, and saved session replay surfaces. + +## 2. Hook Workflow Productization + +- [ ] 2.1 Add inspectable hook registry summaries with enabled state, event grouping, and recent execution health. +- [ ] 2.2 Record async hook completion and failure summaries into session/transcript artifacts without polluting normal assistant output. +- [ ] 2.3 Implement at least one first-class operator workflow for post-edit validation and one for delegated-task completion reporting. + +## 3. Delegated Background Runtime + +- [ ] 3.1 Define a typed delegated-runtime record that can represent background tasks and subagent-style isolated runs. +- [ ] 3.2 Surface live delegated status and replayable delegated summaries in CLI, TUI, and session artifacts. +- [ ] 3.3 Add delegated failure, retry, and recovery summaries that remain bounded and inspectable after session save. + +## 4. Extension Packaging + +- [ ] 4.1 Design and implement a lightweight local extension manifest format for plugins/skill bundles. +- [ ] 4.2 Add extension listing, enable/disable, and source inspection flows. +- [ ] 4.3 Document the local sharing/install workflow for extension bundles and connect it to product help surfaces. + +## 5. Product Readiness Evaluation + +- [ ] 5.1 Expand runtime evaluation outputs to include provider fallback and outage diagnostics alongside runtime profile metrics. +- [ ] 5.2 Add a release-readiness artifact that summarizes runtime health, delegated execution health, and required smoke checks. +- [ ] 5.3 Define and automate a minimal release gate covering session inspection, checkpoint/rewind, and provider fallback UX. + +## 6. Verification And Rollout + +- [ ] 6.1 Add targeted tests for new instruction-layer, hook, delegation, extension, and release-readiness behaviors. +- [ ] 6.2 Run full-repo verification plus product-style smoke checks and save the resulting artifact-backed report. +- [ ] 6.3 Update the product roadmap/report docs so the lightweight-Claude-Code positioning reflects the completed P1-P3 work. +``` + +## openspec/changes/minicode-lite-productization/specs/delegated-background-runtime/spec.md + +- Source: openspec/changes/minicode-lite-productization/specs/delegated-background-runtime/spec.md +- Lines: 1-43 +- SHA256: 4bfc4220ee8031f7b5dc6088328df173929e36c5b4dfbcec8e1b321c4537c9a2 + +```md +## ADDED Requirements + +### Requirement: Delegated executions are bounded and typed +The product SHALL model delegated work as typed runtime units with bounded +context, status, and result contracts. + +#### Scenario: Delegated task starts +- **WHEN** the main runtime launches background or subagent work +- **THEN** the delegated unit records its type, task description, start time, + status, and context mode + +#### Scenario: Delegated task uses isolated context +- **WHEN** the delegated unit is configured for isolated execution +- **THEN** only its final summary and durable artifacts flow back into the main + session by default + +### Requirement: Delegated status is inspectable while running and after completion +The product SHALL let users inspect delegated runtime units during execution and +after the session is saved. + +#### Scenario: User checks live delegated status +- **WHEN** delegated work is running +- **THEN** the CLI or TUI shows active delegated units, status, and latest + progress or result summary + +#### Scenario: User replays a completed session +- **WHEN** a saved session contains delegated runtime activity +- **THEN** session replay includes delegated start, completion, failure, and + retry summaries + +### Requirement: Delegated failures are visible and recoverable +The product SHALL record delegated failure reasons and provide enough state to +retry or diagnose them without polluting the main context. + +#### Scenario: Delegated unit fails +- **WHEN** delegated work errors, stalls, or is cancelled +- **THEN** the runtime records the failure reason and terminal status +- **AND** the saved session preserves that outcome for later inspection + +#### Scenario: Delegated unit is retried +- **WHEN** the operator retries delegated work +- **THEN** the product distinguishes the retried run from the original run +- **AND** the replay surface can show both outcomes +``` + +## openspec/changes/minicode-lite-productization/specs/extension-packaging/spec.md + +- Source: openspec/changes/minicode-lite-productization/specs/extension-packaging/spec.md +- Lines: 1-29 +- SHA256: 8a99187cb6b0b70e77a5b28ac83d4b75485d96d8e7c5cda86be62c027c327434 + +```md +## ADDED Requirements + +### Requirement: Extensions have a lightweight local package format +The product SHALL support a local extension package format for plugins or skill +bundles that can be enabled, disabled, and shared without a remote registry. + +#### Scenario: Extension is installed from a local bundle +- **WHEN** a user installs or enables a local extension bundle +- **THEN** the product records the bundle metadata, source path, and enabled + state + +#### Scenario: Extension is shared across repos or teammates +- **WHEN** a bundle is copied into another local environment +- **THEN** the receiving environment can inspect the same manifest metadata and + enable the extension without manual code changes + +### Requirement: Extension state is inspectable +The product SHALL expose extension enablement and source metadata through +inspectable product surfaces. + +#### Scenario: User lists enabled extensions +- **WHEN** the user inspects product state +- **THEN** the output shows enabled extensions, bundle origin, and declared + capabilities + +#### Scenario: Extension is disabled +- **WHEN** a user disables an extension +- **THEN** the product updates its visible state and does not load the extension + in future turns +``` + +## openspec/changes/minicode-lite-productization/specs/first-class-hook-workflows/spec.md + +- Source: openspec/changes/minicode-lite-productization/specs/first-class-hook-workflows/spec.md +- Lines: 1-40 +- SHA256: 380dfece6967ad1bf86a479754770b0d6b8643c8c6d70c757b2c5f01c8a14399 + +```md +## ADDED Requirements + +### Requirement: Hook registrations are inspectable +The product SHALL expose registered hooks, their enabled state, and their recent +execution health as a user-facing workflow surface. + +#### Scenario: Hook status is requested +- **WHEN** a user inspects hook state from CLI, TUI, or session artifacts +- **THEN** the product lists registered hooks by lifecycle event +- **AND** it shows whether each hook is enabled +- **AND** it shows recent call counts or health summaries + +### Requirement: Async hook outcomes are summarized +The product SHALL report hook outcomes without forcing users to read raw script +or callback chatter. + +#### Scenario: Async hook completes successfully +- **WHEN** an async hook finishes +- **THEN** the runtime records a summarized outcome tied to the originating event +- **AND** the summary can be shown in the session timeline or replay view + +#### Scenario: Async hook fails +- **WHEN** an async hook errors or times out +- **THEN** the runtime records the failure reason +- **AND** it does not crash the main runtime +- **AND** the operator can inspect the failure later + +### Requirement: Hook workflows support common operator automation patterns +The hook system SHALL support user-facing workflows such as post-edit tests and +background completion summaries. + +#### Scenario: Post-edit test workflow is configured +- **WHEN** a file-editing event completes +- **THEN** a configured hook can run an associated validation step +- **AND** the product records the resulting success or failure summary + +#### Scenario: Background task completion hook is configured +- **WHEN** a delegated or background task completes +- **THEN** a configured hook can emit a structured summary back into the main + session timeline +``` + +## openspec/changes/minicode-lite-productization/specs/instruction-policy-layers/spec.md + +- Source: openspec/changes/minicode-lite-productization/specs/instruction-policy-layers/spec.md +- Lines: 1-44 +- SHA256: f1183b8beede9e3dbfcb6d94030145393986fb5dcb081cfa8e69a2fee2f5d93b + +```md +## ADDED Requirements + +### Requirement: Instruction sources are explicit and ordered +The runtime SHALL represent active instruction sources as named layers with a +stable precedence order rather than leaving them implicit in assembled prompt +text. + +#### Scenario: Turn prompt is built +- **WHEN** a turn starts +- **THEN** the runtime records which instruction layers were loaded +- **AND** it records their source path or origin label +- **AND** it records the precedence order used to assemble them + +#### Scenario: Layer order is inspected +- **WHEN** a user inspects the active session or a saved session +- **THEN** the product shows the active instruction layers in precedence order +- **AND** it distinguishes hand-authored policy from auto-loaded memory + +### Requirement: Managed policy can be loaded separately from user and project guidance +The runtime SHALL support a machine-managed policy layer that is distinct from +global user guidance and project guidance. + +#### Scenario: Managed policy file exists +- **WHEN** the runtime loads instructions +- **THEN** it loads the managed policy layer using a documented file path rule +- **AND** it records that the layer is machine-managed + +#### Scenario: Managed policy conflicts with another layer +- **WHEN** multiple layers provide overlapping guidance +- **THEN** the runtime resolves them using the documented precedence order +- **AND** the inspection output shows which layer won + +### Requirement: Instruction inspection is available in product surfaces +The product SHALL expose instruction-layer inspection in at least one CLI path, +one TUI path, and saved session artifacts. + +#### Scenario: User inspects current session +- **WHEN** the user asks for session inspection during a run +- **THEN** the output includes active instruction layers and policy sources + +#### Scenario: User replays a saved session +- **WHEN** the user inspects or replays a saved session +- **THEN** the output includes the instruction-layer summary that was active for + that session snapshot +``` + +## openspec/changes/minicode-lite-productization/specs/product-readiness-evaluation/spec.md + +- Source: openspec/changes/minicode-lite-productization/specs/product-readiness-evaluation/spec.md +- Lines: 1-39 +- SHA256: d9a3398528a1e6d7753897fc15b60aac7e981944b23e38e09903932d56349ef7 + +```md +## ADDED Requirements + +### Requirement: Release-readiness combines runtime and provider health evidence +The evaluation surface SHALL report both local runtime quality and provider +availability behavior when judging readiness. + +#### Scenario: Release-readiness evaluation is run +- **WHEN** a release-style evaluation is executed +- **THEN** the output includes runtime profile metrics, delegated/runtime-event + metrics, and provider fallback or outage outcomes + +#### Scenario: Provider availability is degraded +- **WHEN** the active provider or fallback chain is unavailable +- **THEN** the readiness output records the degraded state explicitly +- **AND** it distinguishes provider outage from local logic failure + +### Requirement: Evaluation outputs are product-readable +The evaluation layer SHALL produce artifacts that operators can inspect without +reading raw traces only. + +#### Scenario: Evaluation report is generated +- **WHEN** runtime evaluation completes +- **THEN** the product emits structured machine-readable data +- **AND** it emits a human-readable summary that explains completion, widening, + guard triggers, and stop reasons + +### Requirement: Release gates can require targeted smoke coverage +The product SHALL support a minimal release gate that requires targeted smoke +checks for critical workflows. + +#### Scenario: Release gate is configured +- **WHEN** a release-readiness run is executed +- **THEN** it can require targeted smoke checks for at least session inspection, + checkpoint/rewind, and provider fallback UX + +#### Scenario: Release gate fails +- **WHEN** a required smoke check or provider fallback diagnostic fails +- **THEN** the readiness output marks the release as not ready +- **AND** it reports which gate failed +``` + diff --git a/openspec/changes/archive/2026-06-05-minicode-lite-productization/.openspec.yaml b/openspec/changes/archive/2026-06-05-minicode-lite-productization/.openspec.yaml new file mode 100644 index 0000000..c53ef21 --- /dev/null +++ b/openspec/changes/archive/2026-06-05-minicode-lite-productization/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-05 diff --git a/openspec/changes/archive/2026-06-05-minicode-lite-productization/design.md b/openspec/changes/archive/2026-06-05-minicode-lite-productization/design.md new file mode 100644 index 0000000..9ff380a --- /dev/null +++ b/openspec/changes/archive/2026-06-05-minicode-lite-productization/design.md @@ -0,0 +1,161 @@ +## Context + +`minicode` has already closed most of the original P0 gap against a lightweight +Claude Code experience: runtime phases are explicit, widening and verification +are typed, sessions are persistent, and edits are recoverable through +checkpoint/rewind. The remaining gap is now product-surface coherence rather than +core loop invention. + +The current codebase already contains the right primitives: + +- instruction loading and prompt assembly: + - `minicode/prompt.py` + - `minicode/prompt_pipeline.py` +- settings and provider runtime validation: + - `minicode/config.py` +- hook lifecycle and script execution: + - `minicode/hooks.py` +- background task tracking: + - `minicode/background_tasks.py` +- runtime control, events, and fallback behavior: + - `minicode/agent_loop.py` +- session inspection, replay, checkpoints, and rewind: + - `minicode/session.py` +- CLI/TUI command and visibility surfaces: + - `minicode/cli_commands.py` + - `minicode/tui/` +- runtime evaluation: + - `minicode/runtime_profile_eval.py` + +The productization work should therefore build on existing surfaces instead of +starting a parallel subsystem. The design also needs to respect the lightweight +goal: local-first, transparent, inspectable, and suitable for a single developer +or a small team without introducing heavy cloud or enterprise control planes. + +## Goals / Non-Goals + +**Goals:** + +- Make active instruction sources explicit and inspectable during a run and after + the session is saved. +- Upgrade hooks from an internal event registry into a visible workflow surface + with outcome summaries and operator controls. +- Turn background and delegated execution into bounded product features with + clean context hygiene, replayable outputs, and inspectable failure reasons. +- Introduce a lightweight extension packaging model for local plugins/skills. +- Add release-ready evaluation and provider/outage diagnostics that explain real + runtime quality, not just unit test health. + +**Non-Goals:** + +- Building a full enterprise policy server or SaaS fleet manager. +- Recreating the complete Claude Code marketplace or remote platform. +- Adding broad IDE integrations in this change. +- Replacing the existing runtime kernel with a new architecture. + +## Decisions + +### Decision 1: Treat P1-P3 as capability layers on top of the existing runtime + +The change will extend the current runtime and session model rather than +introducing a second product stack. This keeps checkpoint/replay/runtime-trace +behavior canonical and avoids split-brain UX across CLI, TUI, and saved session +artifacts. + +Alternatives considered: + +- Build a separate "product shell" around the runtime. + - Rejected because it would duplicate state and weaken recovery/replay. +- Defer productization until a multi-agent rewrite exists. + - Rejected because the current kernel is already strong enough to support a + lightweight product. + +### Decision 2: Model instruction governance as explicit layers, not hidden prompt text + +Instruction loading will be represented as structured layers with precedence and +origin metadata instead of remaining implicit prompt text. The same structured +view should feed turn-time inspection, session artifacts, and future debugging. + +Alternatives considered: + +- Keep layering implicit and only improve docs. + - Rejected because the user experience problem is observability, not + documentation. +- Introduce organization-only policy support first. + - Rejected because this product is meant to stay lightweight and local-first. + +### Decision 3: Productize hooks and delegated runtime through shared event/state summaries + +Hooks, background tasks, and subagents should publish a common state summary that +can appear in CLI/TUI/session replay. This avoids each feature inventing its own +inspection story and keeps context hygiene enforceable. + +Alternatives considered: + +- Let hooks stay internal while only productizing subagents. + - Rejected because hooks are already part of the runtime lifecycle and need + user-visible trust boundaries. +- Add background execution first without replay/state unification. + - Rejected because it would create opaque async behavior. + +### Decision 4: Keep extension packaging local-first and file-based + +The initial plugin/skill packaging layer should use local manifests, local +enable/disable state, and repo-sharable bundles. That yields most of the +lightweight-Claude-Code value without requiring a marketplace. + +Alternatives considered: + +- Build a remote registry from the start. + - Rejected as too heavy for the current product scope. +- Keep extensions as undocumented folder conventions. + - Rejected because packaging and inspectability are the missing product layer. + +### Decision 5: Release readiness must include provider-fallback truth, not only tests + +The evaluation layer will combine runtime profile comparisons, provider fallback +diagnostics, and operator-facing degraded-state reporting. This is necessary +because the recent real-run bottlenecks were provider-availability failures, not +logic bugs. + +Alternatives considered: + +- Limit release gating to unit/integration test pass rates. + - Rejected because it misses the most visible user-facing failures. +- Push provider diagnostics into a separate operational project. + - Rejected because runtime trust depends on it. + +## Risks / Trade-offs + +- [Scope sprawl] -> Keep the work grouped into capability specs with explicit + non-goals and a build plan that sequences P1 before P2/P3 polish. +- [Too much new UX at once] -> Reuse existing session, transcript, and runtime + summary surfaces instead of introducing brand-new panels everywhere. +- [Delegation pollutes context] -> Require isolated result summaries and bounded + replay artifacts before delegated runtime is considered complete. +- [Plugin packaging becomes over-engineered] -> Start with local manifests and + repo-sharable bundles only; defer remote registry concerns. +- [Evaluation becomes disconnected from real usage] -> Keep benchmark outputs and + real provider/outage diagnostics in the same release-readiness surface. + +## Migration Plan + +1. Land OpenSpec capability specs and a build plan for P1-P3. +2. Implement instruction-policy inspection and hook workflows first so later + delegated/runtime features have a stable governance surface. +3. Implement delegated/background runtime and extension packaging with shared + session/replay visibility. +4. Finish product-readiness evaluation and release diagnostics once the new + runtime surfaces are emitting structured state. +5. Verify with targeted product workflows, full repo tests, and release-style + smoke artifacts before closing the change. + +## Open Questions + +- Should managed policy live in a dedicated file path under the user profile, + repo root, or both? +- Should delegated runtime be one generalized task surface, or a thin subagent + layer on top of background tasks plus current task tooling? +- What is the minimal manifest format for local extension packaging that still + supports inspection and reproducibility? +- Which provider fallback checks must be mandatory in release-readiness gating? diff --git a/openspec/changes/archive/2026-06-05-minicode-lite-productization/proposal.md b/openspec/changes/archive/2026-06-05-minicode-lite-productization/proposal.md new file mode 100644 index 0000000..5101dac --- /dev/null +++ b/openspec/changes/archive/2026-06-05-minicode-lite-productization/proposal.md @@ -0,0 +1,60 @@ +## Why + +`minicode` now has a strong runtime kernel, session replay, and checkpoint/rewind +surfaces, but it still feels like an advanced local runtime rather than a +lightweight Claude Code-style product. The next step is to turn the current +kernel into a coherent product surface by finishing the missing P1-P3 layers: +instruction governance, first-class delegation, extensibility, and release-grade +operator ergonomics. + +## What Changes + +- Add explicit instruction and policy layers so users can inspect which global, + user, project, and machine-managed guidance was active in a turn. +- Turn hooks into first-class workflows with inspectable registration, lifecycle, + async execution outcomes, and operator-facing UX in CLI/TUI/session artifacts. +- Productize delegated/background execution so subagents and long-running helper + tasks are bounded, inspectable, replayable, and recoverable. +- Introduce a lightweight extension packaging model for local plugins/skills so + `minicode` can share reusable commands and workflows without needing a heavy + marketplace. +- Expand runtime evaluation and operator diagnostics into a release-readiness + surface that can compare profiles, validate provider fallback behavior, and + explain degraded states clearly. + +## Capabilities + +### New Capabilities + +- `instruction-policy-layers`: Explicit instruction precedence, inspection, and + managed policy loading for runtime turns and session artifacts. +- `first-class-hook-workflows`: Hook registration, visibility, async completion, + and operator workflows that feel like a product surface instead of an internal + API. +- `delegated-background-runtime`: Product-grade background and subagent + execution with inspectable status, isolated outputs, and replayable summaries. +- `extension-packaging`: Lightweight local plugin/skill packaging, discovery, + enablement, and shareable install flows. +- `product-readiness-evaluation`: Release-facing evaluation, provider-fallback + diagnostics, and runtime health reporting that make `minicode-lite` operable. + +### Modified Capabilities + +- None. + +## Impact + +- Affected code: + - `D:/Desktop/minicode/minicode/prompt.py` + - `D:/Desktop/minicode/minicode/prompt_pipeline.py` + - `D:/Desktop/minicode/minicode/config.py` + - `D:/Desktop/minicode/minicode/hooks.py` + - `D:/Desktop/minicode/minicode/background_tasks.py` + - `D:/Desktop/minicode/minicode/agent_loop.py` + - `D:/Desktop/minicode/minicode/session.py` + - `D:/Desktop/minicode/minicode/cli_commands.py` + - `D:/Desktop/minicode/minicode/tui/` + - `D:/Desktop/minicode/minicode/runtime_profile_eval.py` +- New OpenSpec capability specs under + `D:/Desktop/minicode/openspec/changes/minicode-lite-productization/specs/` +- New product-facing docs and build plans in `D:/Desktop/minicode/docs/superpowers/` diff --git a/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/delegated-background-runtime/spec.md b/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/delegated-background-runtime/spec.md new file mode 100644 index 0000000..9d15a70 --- /dev/null +++ b/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/delegated-background-runtime/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Delegated executions are bounded and typed +The product SHALL model delegated work as typed runtime units with bounded +context, status, and result contracts. + +#### Scenario: Delegated task starts +- **WHEN** the main runtime launches background or subagent work +- **THEN** the delegated unit records its type, task description, start time, + status, and context mode + +#### Scenario: Delegated task uses isolated context +- **WHEN** the delegated unit is configured for isolated execution +- **THEN** only its final summary and durable artifacts flow back into the main + session by default + +### Requirement: Delegated status is inspectable while running and after completion +The product SHALL let users inspect delegated runtime units during execution and +after the session is saved. + +#### Scenario: User checks live delegated status +- **WHEN** delegated work is running +- **THEN** the CLI or TUI shows active delegated units, status, and latest + progress or result summary + +#### Scenario: User replays a completed session +- **WHEN** a saved session contains delegated runtime activity +- **THEN** session replay includes delegated start, completion, failure, and + retry summaries + +### Requirement: Delegated failures are visible and recoverable +The product SHALL record delegated failure reasons and provide enough state to +retry or diagnose them without polluting the main context. + +#### Scenario: Delegated unit fails +- **WHEN** delegated work errors, stalls, or is cancelled +- **THEN** the runtime records the failure reason and terminal status +- **AND** the saved session preserves that outcome for later inspection + +#### Scenario: Delegated unit is retried +- **WHEN** the operator retries delegated work +- **THEN** the product distinguishes the retried run from the original run +- **AND** the replay surface can show both outcomes diff --git a/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/extension-packaging/spec.md b/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/extension-packaging/spec.md new file mode 100644 index 0000000..c7f9d34 --- /dev/null +++ b/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/extension-packaging/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Extensions have a lightweight local package format +The product SHALL support a local extension package format for plugins or skill +bundles that can be enabled, disabled, and shared without a remote registry. + +#### Scenario: Extension is installed from a local bundle +- **WHEN** a user installs or enables a local extension bundle +- **THEN** the product records the bundle metadata, source path, and enabled + state + +#### Scenario: Extension is shared across repos or teammates +- **WHEN** a bundle is copied into another local environment +- **THEN** the receiving environment can inspect the same manifest metadata and + enable the extension without manual code changes + +### Requirement: Extension state is inspectable +The product SHALL expose extension enablement and source metadata through +inspectable product surfaces. + +#### Scenario: User lists enabled extensions +- **WHEN** the user inspects product state +- **THEN** the output shows enabled extensions, bundle origin, and declared + capabilities + +#### Scenario: Extension is disabled +- **WHEN** a user disables an extension +- **THEN** the product updates its visible state and does not load the extension + in future turns diff --git a/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/first-class-hook-workflows/spec.md b/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/first-class-hook-workflows/spec.md new file mode 100644 index 0000000..4c325e1 --- /dev/null +++ b/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/first-class-hook-workflows/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Hook registrations are inspectable +The product SHALL expose registered hooks, their enabled state, and their recent +execution health as a user-facing workflow surface. + +#### Scenario: Hook status is requested +- **WHEN** a user inspects hook state from CLI, TUI, or session artifacts +- **THEN** the product lists registered hooks by lifecycle event +- **AND** it shows whether each hook is enabled +- **AND** it shows recent call counts or health summaries + +### Requirement: Async hook outcomes are summarized +The product SHALL report hook outcomes without forcing users to read raw script +or callback chatter. + +#### Scenario: Async hook completes successfully +- **WHEN** an async hook finishes +- **THEN** the runtime records a summarized outcome tied to the originating event +- **AND** the summary can be shown in the session timeline or replay view + +#### Scenario: Async hook fails +- **WHEN** an async hook errors or times out +- **THEN** the runtime records the failure reason +- **AND** it does not crash the main runtime +- **AND** the operator can inspect the failure later + +### Requirement: Hook workflows support common operator automation patterns +The hook system SHALL support user-facing workflows such as post-edit tests and +background completion summaries. + +#### Scenario: Post-edit test workflow is configured +- **WHEN** a file-editing event completes +- **THEN** a configured hook can run an associated validation step +- **AND** the product records the resulting success or failure summary + +#### Scenario: Background task completion hook is configured +- **WHEN** a delegated or background task completes +- **THEN** a configured hook can emit a structured summary back into the main + session timeline diff --git a/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/instruction-policy-layers/spec.md b/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/instruction-policy-layers/spec.md new file mode 100644 index 0000000..6923a52 --- /dev/null +++ b/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/instruction-policy-layers/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Instruction sources are explicit and ordered +The runtime SHALL represent active instruction sources as named layers with a +stable precedence order rather than leaving them implicit in assembled prompt +text. + +#### Scenario: Turn prompt is built +- **WHEN** a turn starts +- **THEN** the runtime records which instruction layers were loaded +- **AND** it records their source path or origin label +- **AND** it records the precedence order used to assemble them + +#### Scenario: Layer order is inspected +- **WHEN** a user inspects the active session or a saved session +- **THEN** the product shows the active instruction layers in precedence order +- **AND** it distinguishes hand-authored policy from auto-loaded memory + +### Requirement: Managed policy can be loaded separately from user and project guidance +The runtime SHALL support a machine-managed policy layer that is distinct from +global user guidance and project guidance. + +#### Scenario: Managed policy file exists +- **WHEN** the runtime loads instructions +- **THEN** it loads the managed policy layer using a documented file path rule +- **AND** it records that the layer is machine-managed + +#### Scenario: Managed policy conflicts with another layer +- **WHEN** multiple layers provide overlapping guidance +- **THEN** the runtime resolves them using the documented precedence order +- **AND** the inspection output shows which layer won + +### Requirement: Instruction inspection is available in product surfaces +The product SHALL expose instruction-layer inspection in at least one CLI path, +one TUI path, and saved session artifacts. + +#### Scenario: User inspects current session +- **WHEN** the user asks for session inspection during a run +- **THEN** the output includes active instruction layers and policy sources + +#### Scenario: User replays a saved session +- **WHEN** the user inspects or replays a saved session +- **THEN** the output includes the instruction-layer summary that was active for + that session snapshot diff --git a/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/product-readiness-evaluation/spec.md b/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/product-readiness-evaluation/spec.md new file mode 100644 index 0000000..c3e4299 --- /dev/null +++ b/openspec/changes/archive/2026-06-05-minicode-lite-productization/specs/product-readiness-evaluation/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Release-readiness combines runtime and provider health evidence +The evaluation surface SHALL report both local runtime quality and provider +availability behavior when judging readiness. + +#### Scenario: Release-readiness evaluation is run +- **WHEN** a release-style evaluation is executed +- **THEN** the output includes runtime profile metrics, delegated/runtime-event + metrics, and provider fallback or outage outcomes + +#### Scenario: Provider availability is degraded +- **WHEN** the active provider or fallback chain is unavailable +- **THEN** the readiness output records the degraded state explicitly +- **AND** it distinguishes provider outage from local logic failure + +### Requirement: Evaluation outputs are product-readable +The evaluation layer SHALL produce artifacts that operators can inspect without +reading raw traces only. + +#### Scenario: Evaluation report is generated +- **WHEN** runtime evaluation completes +- **THEN** the product emits structured machine-readable data +- **AND** it emits a human-readable summary that explains completion, widening, + guard triggers, and stop reasons + +### Requirement: Release gates can require targeted smoke coverage +The product SHALL support a minimal release gate that requires targeted smoke +checks for critical workflows. + +#### Scenario: Release gate is configured +- **WHEN** a release-readiness run is executed +- **THEN** it can require targeted smoke checks for at least session inspection, + checkpoint/rewind, and provider fallback UX + +#### Scenario: Release gate fails +- **WHEN** a required smoke check or provider fallback diagnostic fails +- **THEN** the readiness output marks the release as not ready +- **AND** it reports which gate failed diff --git a/openspec/changes/archive/2026-06-05-minicode-lite-productization/tasks.md b/openspec/changes/archive/2026-06-05-minicode-lite-productization/tasks.md new file mode 100644 index 0000000..697b21d --- /dev/null +++ b/openspec/changes/archive/2026-06-05-minicode-lite-productization/tasks.md @@ -0,0 +1,35 @@ +## 1. Instruction And Policy Layers + +- [x] 1.1 Define the managed policy file-path and precedence rules across global, user, project, and machine-managed instruction layers. +- [x] 1.2 Add structured instruction-layer metadata to prompt assembly and runtime/session inspection outputs. +- [x] 1.3 Expose instruction-layer inspection through CLI, TUI, and saved session replay surfaces. + +## 2. Hook Workflow Productization + +- [x] 2.1 Add inspectable hook registry summaries with enabled state, event grouping, and recent execution health. +- [x] 2.2 Record async hook completion and failure summaries into session/transcript artifacts without polluting normal assistant output. +- [x] 2.3 Implement at least one first-class operator workflow for post-edit validation and one for delegated-task completion reporting. + +## 3. Delegated Background Runtime + +- [x] 3.1 Define a typed delegated-runtime record that can represent background tasks and subagent-style isolated runs. +- [x] 3.2 Surface live delegated status and replayable delegated summaries in CLI, TUI, and session artifacts. +- [x] 3.3 Add delegated failure, retry, and recovery summaries that remain bounded and inspectable after session save. + +## 4. Extension Packaging + +- [x] 4.1 Design and implement a lightweight local extension manifest format for plugins/skill bundles. +- [x] 4.2 Add extension listing, enable/disable, and source inspection flows. +- [x] 4.3 Document the local sharing/install workflow for extension bundles and connect it to product help surfaces. + +## 5. Product Readiness Evaluation + +- [x] 5.1 Expand runtime evaluation outputs to include provider fallback and outage diagnostics alongside runtime profile metrics. +- [x] 5.2 Add a release-readiness artifact that summarizes runtime health, delegated execution health, and required smoke checks. +- [x] 5.3 Define and automate a minimal release gate covering session inspection, checkpoint/rewind, and provider fallback UX. + +## 6. Verification And Rollout + +- [x] 6.1 Add targeted tests for new instruction-layer, hook, delegation, extension, and release-readiness behaviors. +- [x] 6.2 Run full-repo verification plus product-style smoke checks and save the resulting artifact-backed report. +- [x] 6.3 Update the product roadmap/report docs so the lightweight-Claude-Code positioning reflects the completed P1-P3 work. diff --git a/openspec/specs/delegated-background-runtime/spec.md b/openspec/specs/delegated-background-runtime/spec.md new file mode 100644 index 0000000..9d15a70 --- /dev/null +++ b/openspec/specs/delegated-background-runtime/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Delegated executions are bounded and typed +The product SHALL model delegated work as typed runtime units with bounded +context, status, and result contracts. + +#### Scenario: Delegated task starts +- **WHEN** the main runtime launches background or subagent work +- **THEN** the delegated unit records its type, task description, start time, + status, and context mode + +#### Scenario: Delegated task uses isolated context +- **WHEN** the delegated unit is configured for isolated execution +- **THEN** only its final summary and durable artifacts flow back into the main + session by default + +### Requirement: Delegated status is inspectable while running and after completion +The product SHALL let users inspect delegated runtime units during execution and +after the session is saved. + +#### Scenario: User checks live delegated status +- **WHEN** delegated work is running +- **THEN** the CLI or TUI shows active delegated units, status, and latest + progress or result summary + +#### Scenario: User replays a completed session +- **WHEN** a saved session contains delegated runtime activity +- **THEN** session replay includes delegated start, completion, failure, and + retry summaries + +### Requirement: Delegated failures are visible and recoverable +The product SHALL record delegated failure reasons and provide enough state to +retry or diagnose them without polluting the main context. + +#### Scenario: Delegated unit fails +- **WHEN** delegated work errors, stalls, or is cancelled +- **THEN** the runtime records the failure reason and terminal status +- **AND** the saved session preserves that outcome for later inspection + +#### Scenario: Delegated unit is retried +- **WHEN** the operator retries delegated work +- **THEN** the product distinguishes the retried run from the original run +- **AND** the replay surface can show both outcomes diff --git a/openspec/specs/extension-packaging/spec.md b/openspec/specs/extension-packaging/spec.md new file mode 100644 index 0000000..c7f9d34 --- /dev/null +++ b/openspec/specs/extension-packaging/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Extensions have a lightweight local package format +The product SHALL support a local extension package format for plugins or skill +bundles that can be enabled, disabled, and shared without a remote registry. + +#### Scenario: Extension is installed from a local bundle +- **WHEN** a user installs or enables a local extension bundle +- **THEN** the product records the bundle metadata, source path, and enabled + state + +#### Scenario: Extension is shared across repos or teammates +- **WHEN** a bundle is copied into another local environment +- **THEN** the receiving environment can inspect the same manifest metadata and + enable the extension without manual code changes + +### Requirement: Extension state is inspectable +The product SHALL expose extension enablement and source metadata through +inspectable product surfaces. + +#### Scenario: User lists enabled extensions +- **WHEN** the user inspects product state +- **THEN** the output shows enabled extensions, bundle origin, and declared + capabilities + +#### Scenario: Extension is disabled +- **WHEN** a user disables an extension +- **THEN** the product updates its visible state and does not load the extension + in future turns diff --git a/openspec/specs/first-class-hook-workflows/spec.md b/openspec/specs/first-class-hook-workflows/spec.md new file mode 100644 index 0000000..4c325e1 --- /dev/null +++ b/openspec/specs/first-class-hook-workflows/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Hook registrations are inspectable +The product SHALL expose registered hooks, their enabled state, and their recent +execution health as a user-facing workflow surface. + +#### Scenario: Hook status is requested +- **WHEN** a user inspects hook state from CLI, TUI, or session artifacts +- **THEN** the product lists registered hooks by lifecycle event +- **AND** it shows whether each hook is enabled +- **AND** it shows recent call counts or health summaries + +### Requirement: Async hook outcomes are summarized +The product SHALL report hook outcomes without forcing users to read raw script +or callback chatter. + +#### Scenario: Async hook completes successfully +- **WHEN** an async hook finishes +- **THEN** the runtime records a summarized outcome tied to the originating event +- **AND** the summary can be shown in the session timeline or replay view + +#### Scenario: Async hook fails +- **WHEN** an async hook errors or times out +- **THEN** the runtime records the failure reason +- **AND** it does not crash the main runtime +- **AND** the operator can inspect the failure later + +### Requirement: Hook workflows support common operator automation patterns +The hook system SHALL support user-facing workflows such as post-edit tests and +background completion summaries. + +#### Scenario: Post-edit test workflow is configured +- **WHEN** a file-editing event completes +- **THEN** a configured hook can run an associated validation step +- **AND** the product records the resulting success or failure summary + +#### Scenario: Background task completion hook is configured +- **WHEN** a delegated or background task completes +- **THEN** a configured hook can emit a structured summary back into the main + session timeline diff --git a/openspec/specs/instruction-policy-layers/spec.md b/openspec/specs/instruction-policy-layers/spec.md new file mode 100644 index 0000000..6923a52 --- /dev/null +++ b/openspec/specs/instruction-policy-layers/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Instruction sources are explicit and ordered +The runtime SHALL represent active instruction sources as named layers with a +stable precedence order rather than leaving them implicit in assembled prompt +text. + +#### Scenario: Turn prompt is built +- **WHEN** a turn starts +- **THEN** the runtime records which instruction layers were loaded +- **AND** it records their source path or origin label +- **AND** it records the precedence order used to assemble them + +#### Scenario: Layer order is inspected +- **WHEN** a user inspects the active session or a saved session +- **THEN** the product shows the active instruction layers in precedence order +- **AND** it distinguishes hand-authored policy from auto-loaded memory + +### Requirement: Managed policy can be loaded separately from user and project guidance +The runtime SHALL support a machine-managed policy layer that is distinct from +global user guidance and project guidance. + +#### Scenario: Managed policy file exists +- **WHEN** the runtime loads instructions +- **THEN** it loads the managed policy layer using a documented file path rule +- **AND** it records that the layer is machine-managed + +#### Scenario: Managed policy conflicts with another layer +- **WHEN** multiple layers provide overlapping guidance +- **THEN** the runtime resolves them using the documented precedence order +- **AND** the inspection output shows which layer won + +### Requirement: Instruction inspection is available in product surfaces +The product SHALL expose instruction-layer inspection in at least one CLI path, +one TUI path, and saved session artifacts. + +#### Scenario: User inspects current session +- **WHEN** the user asks for session inspection during a run +- **THEN** the output includes active instruction layers and policy sources + +#### Scenario: User replays a saved session +- **WHEN** the user inspects or replays a saved session +- **THEN** the output includes the instruction-layer summary that was active for + that session snapshot diff --git a/openspec/specs/product-readiness-evaluation/spec.md b/openspec/specs/product-readiness-evaluation/spec.md new file mode 100644 index 0000000..c3e4299 --- /dev/null +++ b/openspec/specs/product-readiness-evaluation/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Release-readiness combines runtime and provider health evidence +The evaluation surface SHALL report both local runtime quality and provider +availability behavior when judging readiness. + +#### Scenario: Release-readiness evaluation is run +- **WHEN** a release-style evaluation is executed +- **THEN** the output includes runtime profile metrics, delegated/runtime-event + metrics, and provider fallback or outage outcomes + +#### Scenario: Provider availability is degraded +- **WHEN** the active provider or fallback chain is unavailable +- **THEN** the readiness output records the degraded state explicitly +- **AND** it distinguishes provider outage from local logic failure + +### Requirement: Evaluation outputs are product-readable +The evaluation layer SHALL produce artifacts that operators can inspect without +reading raw traces only. + +#### Scenario: Evaluation report is generated +- **WHEN** runtime evaluation completes +- **THEN** the product emits structured machine-readable data +- **AND** it emits a human-readable summary that explains completion, widening, + guard triggers, and stop reasons + +### Requirement: Release gates can require targeted smoke coverage +The product SHALL support a minimal release gate that requires targeted smoke +checks for critical workflows. + +#### Scenario: Release gate is configured +- **WHEN** a release-readiness run is executed +- **THEN** it can require targeted smoke checks for at least session inspection, + checkpoint/rewind, and provider fallback UX + +#### Scenario: Release gate fails +- **WHEN** a required smoke check or provider fallback diagnostic fails +- **THEN** the readiness output marks the release as not ready +- **AND** it reports which gate failed diff --git a/py-src/minicode/agent_loop.py b/py-src/minicode/agent_loop.py index 6ef4aab..bc01331 100644 --- a/py-src/minicode/agent_loop.py +++ b/py-src/minicode/agent_loop.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations import concurrent.futures import inspect @@ -27,6 +27,27 @@ from minicode.capability_registry import get_registry, CapabilityDomain from minicode.layered_context import ContextBuilder, LayeredContext, ContextLayer from minicode.decision_audit import get_auditor, DecisionType, DecisionOutcome +from minicode.turn_kernel import ( + AssistantMessageReplay, + AssistantTurnDecision, + ToolBatchPlan, + ToolReplayPlan, + ToolResultDecision, + ToolResultReplay, + ToolStepFeedback, + apply_tool_step_feedback, + build_tool_batch_plan, + build_tool_replay_plan, + build_tool_result_replay, + build_assistant_turn_replay, + build_step_content_replay, + build_tool_step_feedback, + build_turn_coda_summary, + finalize_work_chain_task, + TurnPreludeState, + TurnRecurrentState, + decide_assistant_turn, +) # 工程控制论集成 from minicode.feedback_controller import FeedbackController, SystemState @@ -206,11 +227,11 @@ def _execute_single_tool( on_tool_start: Callable[[str, dict], None] | None, on_tool_result: Callable[[str, str, bool], None] | None, ) -> ToolResult: - """Execute a single tool call with hooks, state updates, and crash protection. + """Execute a single tool call with state updates, callbacks, and crash protection. Used both for serial execution and as a worker function for concurrent execution. When running concurrently (store/on_tool_start/on_tool_result are None), - hooks and UI callbacks are deferred to the result processing phase. + UI callbacks are deferred to the result processing phase. Includes a global exception safety net: any unexpected crash in the tool execution pipeline (hooks, state updates, etc.) is caught and converted @@ -348,21 +369,13 @@ def run_agent_turn( enable_work_chain: bool = True, ) -> list[ChatMessage]: current_messages = list(messages) - saw_tool_result = False - empty_response_retry_count = 0 - recoverable_thinking_retry_count = 0 - tool_error_count = 0 - step = 0 + turn_state = TurnRecurrentState(max_steps=max_steps) tool_scheduler = ToolScheduler(metrics_collector=metrics_collector) - # Initialize work chain if enabled - task: TaskObject | None = None - task_metadata: dict = {} - layered_context: LayeredContext | None = None - context_builder: ContextBuilder | None = None + # Prelude: initialize once-per-turn work chain artifacts. + prelude = TurnPreludeState(auditor=get_auditor() if enable_work_chain else None) pipeline_engine: PipelineEngine | None = None - auditor = get_auditor() if enable_work_chain else None # 工程控制论控制器初始化 feedback_controller: FeedbackController | None = None @@ -377,9 +390,9 @@ def run_agent_turn( self_healing_engine: SelfHealingEngine | None = None if enable_work_chain: - task, task_metadata = _build_work_chain_task(current_messages) - layered_context, context_builder = _build_layered_context( - current_messages, system_prompt, project_context, task, + prelude.task, prelude.task_metadata = _build_work_chain_task(current_messages) + prelude.layered_context, prelude.context_builder = _build_layered_context( + current_messages, system_prompt, project_context, prelude.task, ) pipeline_engine = get_pipeline_engine() _register_tool_capabilities(tools) @@ -389,10 +402,14 @@ def run_agent_turn( logger.info("Feedback controller initialized: negative + positive feedback loops") # 初始化前馈控制器(预判式优化) - if task: + if prelude.task: feedforward_controller = FeedforwardController() - preemptive_config = feedforward_controller.preconfigure(task.parsed_intent, task.raw_input) - risk_assessment = feedforward_controller.assess_risks(task.parsed_intent, preemptive_config) + preemptive_config = feedforward_controller.preconfigure( + prelude.task.parsed_intent, prelude.task.raw_input + ) + risk_assessment = feedforward_controller.assess_risks( + prelude.task.parsed_intent, preemptive_config + ) logger.info( "Feedforward control: config=%s risk=%s", preemptive_config.recommended_model, risk_assessment.risk_level, @@ -474,16 +491,20 @@ def run_agent_turn( adj = cost_control.run( cost_usd=est_cost, total_tokens=stats.total_tokens, - total_calls=max(step, 1), + total_calls=max(turn_state.step, 1), ) if context_compactor._tool_budget: cost_control.apply_to_budget_manager(context_compactor._tool_budget) cyber_messages, cyber_result, cyber_action = context_cybernetics.run_cycle( current_messages, - error_rate=float(tool_error_count) / max(step, 1) if step > 0 else 0.0, - avg_latency=step * 2.0, - turn_id=step, + error_rate=( + float(turn_state.tool_error_count) / max(turn_state.step, 1) + if turn_state.step > 0 + else 0.0 + ), + avg_latency=turn_state.step * 2.0, + turn_id=turn_state.step, ) if cyber_result and cyber_result.effective: current_messages = cyber_messages @@ -514,8 +535,8 @@ def run_agent_turn( on_assistant_message(context_manager.get_context_summary()) try: - while max_steps is None or step < max_steps: - step += 1 + while turn_state.has_remaining_steps(): + step = turn_state.begin_step() # Hook: agent turn started fire_hook_sync(HookEvent.AGENT_START, step=step, cwd=cwd) @@ -527,9 +548,9 @@ def run_agent_turn( measurement = MeasurementVector( timestamp=time.time(), response_time=step * 2.0, # 估算响应时间 - success_rate=1.0 - (tool_error_count / max(step, 1)), + success_rate=1.0 - (turn_state.tool_error_count / max(step, 1)), context_length=context_manager.get_stats().total_tokens if context_manager else 0, - error_count=tool_error_count, + error_count=turn_state.tool_error_count, tool_calls=0, ) observed_state = state_observer.update(measurement) @@ -539,7 +560,9 @@ def run_agent_turn( if context_manager: stats = context_manager.get_stats() predictive_controller.update("context_usage", stats.usage_percentage / 100.0) - predictive_controller.update("error_rate", tool_error_count / max(step, 1)) + predictive_controller.update( + "error_rate", turn_state.tool_error_count / max(step, 1) + ) if step > 2: actions = predictive_controller.generate_predictive_actions() @@ -548,7 +571,7 @@ def run_agent_turn( if self_healing_engine: healing_actions = self_healing_engine.detect_and_heal({ "context_usage": stats.usage_percentage / 100.0 if context_manager else 0.0, - "error_rate": tool_error_count / max(step, 1), + "error_rate": turn_state.tool_error_count / max(step, 1), }) if healing_actions: logger.info("Self-healing: %s", healing_actions[0].strategy) @@ -625,118 +648,74 @@ def run_agent_turn( if next_step.type == "assistant": is_empty = _is_empty_assistant_response(next_step.content) - if not is_empty and _should_treat_assistant_as_progress( - kind=getattr(next_step, 'kind', None), - content=next_step.content, - saw_tool_result=saw_tool_result, - ): - if on_progress_message: - on_progress_message(next_step.content) - current_messages.append({"role": "assistant_progress", "content": next_step.content}) - current_messages.append( - { - "role": "user", - "content": ( - NUDGE_AFTER_TOOL_RESULT - if saw_tool_result and getattr(next_step, 'kind', None) != "progress" - else NUDGE_CONTINUE - ), - } - ) - continue - diagnostics = next_step.diagnostics - - if _is_recoverable_thinking_stop( - is_empty=is_empty, + decision: AssistantTurnDecision = decide_assistant_turn( + turn_state=turn_state, + step_content=next_step.content, + step_kind=getattr(next_step, "kind", None), stop_reason=diagnostics.stopReason if diagnostics else None, + block_types=diagnostics.blockTypes if diagnostics else None, ignored_block_types=diagnostics.ignoredBlockTypes if diagnostics else None, - ) and recoverable_thinking_retry_count < 3: - recoverable_thinking_retry_count += 1 - stop_reason = diagnostics.stopReason if diagnostics else None - progress_content = ( - "Model hit max_tokens during thinking; requesting the next step." - if stop_reason == "max_tokens" - else "Model returned pause_turn; requesting the next step." - ) - if on_progress_message: - on_progress_message(progress_content) - current_messages.append({"role": "assistant_progress", "content": progress_content}) - current_messages.append( - { - "role": "user", - "content": ( - RESUME_AFTER_PAUSE - if stop_reason == "pause_turn" - else RESUME_AFTER_MAX_TOKENS - ), - } - ) - continue + is_empty=is_empty, + treat_as_progress=( + not is_empty + and _should_treat_assistant_as_progress( + kind=getattr(next_step, "kind", None), + content=next_step.content, + saw_tool_result=turn_state.saw_tool_result, + ) + ), + is_recoverable_thinking_stop=_is_recoverable_thinking_stop( + is_empty=is_empty, + stop_reason=diagnostics.stopReason if diagnostics else None, + ignored_block_types=diagnostics.ignoredBlockTypes if diagnostics else None, + ), + format_diagnostics=_format_diagnostics, + nudge_continue=NUDGE_CONTINUE, + nudge_after_tool_result=NUDGE_AFTER_TOOL_RESULT, + resume_after_pause=RESUME_AFTER_PAUSE, + resume_after_max_tokens=RESUME_AFTER_MAX_TOKENS, + nudge_after_empty_response=NUDGE_AFTER_EMPTY_RESPONSE, + nudge_after_empty_no_tools=NUDGE_AFTER_EMPTY_NO_TOOLS, + ) - if is_empty and empty_response_retry_count < 2: - empty_response_retry_count += 1 - current_messages.append( - { - "role": "user", - "content": ( - NUDGE_AFTER_EMPTY_RESPONSE - if saw_tool_result - else NUDGE_AFTER_EMPTY_NO_TOOLS - ), - } - ) - continue + assistant_replay: AssistantMessageReplay = build_assistant_turn_replay( + decision=decision + ) + if assistant_replay.callback_kind == "progress" and on_progress_message and assistant_replay.callback_content: + on_progress_message(assistant_replay.callback_content) + elif assistant_replay.callback_kind == "assistant" and on_assistant_message and assistant_replay.callback_content: + on_assistant_message(assistant_replay.callback_content) - if is_empty: - diagnostics_suffix = _format_diagnostics( - diagnostics.stopReason if diagnostics else None, - diagnostics.blockTypes if diagnostics else None, - diagnostics.ignoredBlockTypes if diagnostics else None, - ) - if saw_tool_result: - fallback = ( - f"Model returned an empty response after tool execution and the turn was stopped. There were {tool_error_count} tool error(s); retry, adjust the command, or choose a different approach.{diagnostics_suffix}" - if tool_error_count > 0 - else f"Model returned an empty response after tool execution and the turn was stopped. Retry or ask the model to continue the remaining steps.{diagnostics_suffix}" - ) - else: - fallback = f"Model returned an empty response and the turn was stopped.{diagnostics_suffix}" - if on_assistant_message: - on_assistant_message(fallback) - current_messages.append({"role": "assistant", "content": fallback}) - return current_messages + current_messages.extend(assistant_replay.transcript_messages) + if not assistant_replay.should_return: + continue - if on_assistant_message: - on_assistant_message(next_step.content) - current_messages.append({"role": "assistant", "content": next_step.content}) # Protect final answer in working memory - protect_context( - content=next_step.content[:500], - entry_type="key_decision", - ttl_seconds=3600, - ) + if assistant_replay.protect_final_answer and assistant_replay.callback_content: + protect_context( + content=assistant_replay.callback_content[:500], + entry_type="key_decision", + ttl_seconds=3600, + ) return current_messages if next_step.content: - role = "assistant_progress" if next_step.contentKind == "progress" else "assistant" - if role == "assistant_progress": - if on_progress_message: - on_progress_message(next_step.content) - current_messages.append({"role": role, "content": next_step.content}) - current_messages.append( - { - "role": "user", - "content": NUDGE_CONTINUE, - } - ) - else: - if on_assistant_message: - on_assistant_message(next_step.content) - current_messages.append({"role": role, "content": next_step.content}) - - if not next_step.calls and next_step.content and next_step.contentKind != "progress": - return current_messages + step_content_replay: AssistantMessageReplay = build_step_content_replay( + content=next_step.content, + content_kind=next_step.contentKind, + has_calls=bool(next_step.calls), + nudge_continue=NUDGE_CONTINUE, + ) + if step_content_replay.callback_kind == "progress": + if on_progress_message and step_content_replay.callback_content: + on_progress_message(step_content_replay.callback_content) + elif step_content_replay.callback_kind == "assistant": + if on_assistant_message and step_content_replay.callback_content: + on_assistant_message(step_content_replay.callback_content) + current_messages.extend(step_content_replay.transcript_messages) + if step_content_replay.should_return: + return current_messages # --- Concurrent tool execution --- # Classify calls into concurrent-safe (read-only) vs serial (writes/commands) @@ -746,6 +725,12 @@ def run_agent_turn( if len(calls) <= 1: # Single call — no benefit from concurrency, run directly call = calls[0] + fire_hook_sync( + HookEvent.PRE_TOOL_USE, + tool_name=call["toolName"], + tool_input=call["input"], + step=step, + ) if metrics_collector: metrics_collector.start_tool(call["toolName"]) result = _execute_single_tool( @@ -761,14 +746,28 @@ def run_agent_turn( else: # Multiple calls — use ToolScheduler for intelligent partitioning concurrent_calls, serial_calls = tool_scheduler.schedule_calls(calls, tools) + batch_plan: ToolBatchPlan = build_tool_batch_plan( + calls=calls, + concurrent_calls=concurrent_calls, + serial_calls=serial_calls, + get_recommended_max_workers=tool_scheduler.get_recommended_max_workers, + ) _results: list[tuple[dict, ToolResult]] = [] # Phase 1: Run all concurrent-safe tools in parallel - if concurrent_calls: - max_workers = tool_scheduler.get_recommended_max_workers(concurrent_calls) + if batch_plan.concurrent_calls: + for call in batch_plan.concurrent_calls: + if on_tool_start: + on_tool_start(call["toolName"], call["input"]) + fire_hook_sync( + HookEvent.PRE_TOOL_USE, + tool_name=call["toolName"], + tool_input=call["input"], + step=step, + ) with concurrent.futures.ThreadPoolExecutor( - max_workers=max_workers, + max_workers=batch_plan.max_workers, thread_name_prefix="mc-tool", ) as pool: future_to_call = { @@ -777,7 +776,7 @@ def run_agent_turn( call, tools, cwd, permissions, runtime, None, step, None, None, # No UI callbacks during concurrent phase ): call - for call in concurrent_calls + for call in batch_plan.concurrent_calls } for future in concurrent.futures.as_completed(future_to_call): call = future_to_call[future] @@ -788,8 +787,14 @@ def run_agent_turn( _results.append((call, result)) # Phase 2: Run serial tools sequentially (in original order) - if serial_calls: - for call in serial_calls: + if batch_plan.serial_calls: + for call in batch_plan.serial_calls: + fire_hook_sync( + HookEvent.PRE_TOOL_USE, + tool_name=call["toolName"], + tool_input=call["input"], + step=step, + ) if metrics_collector: metrics_collector.start_tool(call["toolName"]) result = _execute_single_tool( @@ -807,31 +812,18 @@ def run_agent_turn( # Still need to process remaining results for messages break - # Process all results and build messages (preserve original call order) - call_order = {call["id"]: idx for idx, call in enumerate(calls)} - _results.sort(key=lambda pair: call_order.get(pair[0]["id"], 999)) - - for call, result in _results: + replay_plan: ToolReplayPlan = build_tool_replay_plan( + calls=calls, + all_results=_results, + is_concurrency_safe=lambda tool_name: bool( + (tool_def := tools.find(tool_name)) and tool_def.is_concurrency_safe + ), + ) + + await_user_assistant_content: str | None = None + for call, result in replay_plan.ordered_results: # Fire hooks and UI callbacks for concurrent calls (deferred) tool_def = tools.find(call["toolName"]) - is_concurrent = tool_def and tool_def.is_concurrency_safe and len(calls) > 1 - - if is_concurrent: - # Deferred UI callbacks for concurrent tools - if on_tool_start: - on_tool_start(call["toolName"], call["input"]) - if store: - store.set_state(set_busy(call["toolName"])) - store.set_state(increment_tool_calls()) - store.set_state(set_idle()) - # Hook: pre-tool-use (fire after the fact for concurrent tools) - fire_hook_sync( - HookEvent.PRE_TOOL_USE, - tool_name=call["toolName"], - tool_input=call["input"], - step=step, - ) - # Hook: post-tool-use fire_hook_sync( HookEvent.POST_TOOL_USE, @@ -840,95 +832,93 @@ def run_agent_turn( is_error=not result.ok, step=step, ) - - if is_concurrent: + + tool_replay: ToolResultReplay = build_tool_result_replay( + call=call, + result=result, + turn_state=turn_state, + total_call_count=len(calls), + is_concurrency_safe=bool(tool_def and tool_def.is_concurrency_safe), + all_results=_results, + dedup_manager=( + context_compactor.read_dedup + if context_compactor + else None + ), + message_index=len(current_messages), + classify_error=lambda output, tool_name: ErrorClassifier.classify( + output, + tool_name=tool_name, + ), + generate_nudge=lambda classified, retry_count: NudgeGenerator.generate( + classified, + retry_count=retry_count, + ), + log_dedup=lambda file_path: logger.debug( + "ReadDedup replaced content for %s (stub)", + file_path, + ), + ) + + if tool_replay.should_emit_callback: if on_tool_result: - on_tool_result(call["toolName"], result.output, not result.ok) - - saw_tool_result = True - if not result.ok: - tool_error_count += 1 - # Use ErrorClassifier for intelligent error handling - classified = ErrorClassifier.classify(result.output, tool_name=call["toolName"]) - nudge = NudgeGenerator.generate(classified, retry_count=tool_error_count) - # Append nudge to tool result content for model context - result_output = result.output + "\n\n[System note: " + nudge + "]" - else: - result_output = result.output + on_tool_result( + call["toolName"], + tool_replay.callback_output, + not result.ok, + ) + if store and tool_replay.should_increment_tool_calls: + store.set_state(increment_tool_calls()) # Record conflicts between concurrent tools if both failed - if not result.ok and len(calls) > 1: - for other_call, other_result in _results: - if other_call["id"] == call["id"]: - continue - if not other_result.ok: - tool_scheduler.record_conflict(call["toolName"], other_call["toolName"]) + for conflicting_tool_name in tool_replay.conflicting_tool_names: + tool_scheduler.record_conflict( + call["toolName"], + conflicting_tool_name, + ) # ReadDedup: 去重相同文件的重复读取,节省上下文空间 - if ( - context_compactor - and result.ok - and call.get("toolName") == "read_file" - ): - file_path = call.get("input", {}).get("path", "") - if file_path: - dedup_mgr = context_compactor.read_dedup - if dedup_mgr.should_dedup(file_path, result_output): - result_output = dedup_mgr.get_stub(file_path) - logger.debug("ReadDedup replaced content for %s (stub)", file_path) - dedup_mgr.register_read(file_path, result_output, len(current_messages)) + tool_decision: ToolResultDecision = tool_replay.tool_decision + current_messages.extend(tool_decision.transcript_messages) + if tool_decision.should_return and await_user_assistant_content is None: + await_user_assistant_content = tool_decision.assistant_content + if await_user_assistant_content is not None: + if on_assistant_message: + on_assistant_message(await_user_assistant_content) current_messages.append( - { - "role": "assistant_tool_call", - "toolUseId": call["id"], - "toolName": call["toolName"], - "input": call["input"], - } - ) - current_messages.append( - { - "role": "tool_result", - "toolUseId": call["id"], - "toolName": call["toolName"], - "content": result_output, - "isError": not result.ok, - } + {"role": "assistant", "content": await_user_assistant_content} ) - if result.awaitUser: - if on_assistant_message: - on_assistant_message(result_output) - current_messages.append({"role": "assistant", "content": result_output}) - if metrics_collector: - metrics_collector.end_turn(total_tokens=0) - return current_messages + if metrics_collector: + metrics_collector.end_turn(total_tokens=0) + return current_messages # 工具执行完成后的控制论反馈 if enable_work_chain: # 多变量解耦:消除工具间的耦合影响 - if decoupling_controller: - decoupling_controller.record_measurement({ - "token_usage_to_latency": ( - context_manager.get_stats().usage_percentage / 100.0 if context_manager else 0.0, - step * 2.0 / 60.0, - ), - "context_pressure_to_errors": ( - context_manager.get_stats().usage_percentage / 100.0 if context_manager else 0.0, - tool_error_count / max(step, 1), - ), - }) - decoupling_controller.compute_decoupling_matrix() + step_feedback: ToolStepFeedback = build_tool_step_feedback( + turn_state=turn_state, + context_usage=( + context_manager.get_stats().usage_percentage / 100.0 + if context_manager + else 0.0 + ), + oscillation_index=( + feedback_controller._compute_oscillation() + if feedback_controller + else 0.0 + ), + ) # 自愈检测:检测并修复故障 - if self_healing_engine: - metrics_for_healing = { - "error_rate": tool_error_count / max(step, 1), - "context_usage": context_manager.get_stats().usage_percentage / 100.0 if context_manager else 0.0, - "oscillation_index": feedback_controller._compute_oscillation() if feedback_controller else 0.0, - } - healing_actions = self_healing_engine.detect_and_heal(metrics_for_healing) - if healing_actions: - logger.info("Self-healing triggered: %s", healing_actions[0].strategy) + apply_tool_step_feedback( + feedback=step_feedback, + decoupling_controller=decoupling_controller, + self_healing_engine=self_healing_engine, + log_healing=lambda strategy: logger.info( + "Self-healing triggered: %s", strategy + ), + ) # Tool execution completed for this step; ask the model for the next turn # instead of falling through to the max-step fallback. @@ -945,7 +935,23 @@ def run_agent_turn( current_messages.append({"role": "assistant", "content": fallback}) return current_messages finally: - fire_hook_sync(HookEvent.AGENT_STOP, step=step, tool_errors=tool_error_count) + # Coda: finalize metrics, work-chain bookkeeping, and control summaries. + fire_hook_sync( + HookEvent.AGENT_STOP, + step=turn_state.step, + tool_errors=turn_state.tool_error_count, + ) + task = prelude.task + task_metadata = prelude.task_metadata + auditor = prelude.auditor + coda_summary = build_turn_coda_summary( + turn_state=turn_state, + context_usage=( + context_manager.get_stats().usage_percentage + if context_manager + else 0.0 + ), + ) if metrics_collector and metrics_collector._current_turn is not None: total_tokens = sum( @@ -954,29 +960,27 @@ def run_agent_turn( metrics_collector.end_turn(total_tokens=total_tokens) if enable_work_chain and task: - final_state = TaskState.COMPLETED if tool_error_count == 0 else TaskState.FAILED - task.set_state(final_state) - task.result_summary = f"Turn completed: {step} steps, {tool_error_count} errors" - - if auditor: - outcome = DecisionOutcome.SUCCESS if tool_error_count == 0 else DecisionOutcome.FAILURE - auditor.complete_decision( - outcome, - step * 100.0, - task.result_summary, - task.error_message if tool_error_count > 0 else "", - ) + finalize_work_chain_task( + task=task, + auditor=auditor, + coda_summary=coda_summary, + success_outcome=DecisionOutcome.SUCCESS, + failure_outcome=DecisionOutcome.FAILURE, + ) logger.info( "Work chain completed: task=%s state=%s steps=%d errors=%d", - task.id, task.state.value, step, tool_error_count, + task.id, + task.state.value, + coda_summary.step, + coda_summary.tool_error_count, ) # 控制论反馈:记录模式有效性 if enable_work_chain and feedback_controller and task: pattern_id = f"{task_metadata.get('intent_type', 'unknown')}_{task.id}" feedback_controller.record_pattern_effectiveness( - pattern_id, tool_error_count == 0 + pattern_id, coda_summary.success ) # 稳定性监测:记录快照 @@ -984,9 +988,9 @@ def run_agent_turn( from minicode.stability_monitor import MetricSnapshot snapshot = MetricSnapshot( timestamp=time.time(), - error_rate=float(tool_error_count) / max(step, 1), - avg_latency=step * 2.0, # 简化估算 - context_usage=context_manager.get_stats().usage_percentage if context_manager else 0.0, + error_rate=coda_summary.error_rate, + avg_latency=coda_summary.avg_latency, # 简化估算 + context_usage=coda_summary.context_usage, active_tasks=1, ) stability_monitor.record_snapshot(snapshot) @@ -1068,3 +1072,4 @@ def run_agent_turn( system_state.performance_score(), ) + diff --git a/py-src/minicode/tui/chrome.py b/py-src/minicode/tui/chrome.py index 3e1b454..a67df24 100644 --- a/py-src/minicode/tui/chrome.py +++ b/py-src/minicode/tui/chrome.py @@ -455,6 +455,24 @@ def render_status_line(status: str | None) -> str: return f"{t.assistant}{ICON_SUCCESS} Ready{t.reset}" +def _truncate_tool_activity_label(label: str, max_width: int) -> str: + if max_width <= 0: + return "" + if string_display_width(label) <= max_width: + return label + + if " path=" in label: + head, path = label.split(" path=", 1) + head = truncate_plain(head, max(6, min(max_width - 8, string_display_width(head)))) + remaining = max(5, max_width - string_display_width(head) - 6) + return f"{head} path={truncate_path_middle(path, remaining)}" + + if "\\" in label or "/" in label: + return truncate_path_middle(label, max_width) + + return truncate_plain(label, max_width) + + def render_tool_panel( active_tool: str | None, recent_tools: list[dict[str, str]], @@ -464,23 +482,59 @@ def render_tool_panel( t = theme() if background_tasks is None: background_tasks = [] + width, _ = _cached_terminal_size() + label_width = max(16, min(32, (max(width, 40) - 18) // 3)) parts: list[str] = [] if active_tool: - parts.append(f"{ICON_RUNNING} {t.tool}{t.bold}running{t.reset} {active_tool}") + parts.append( + f"{ICON_RUNNING} {t.tool}{t.bold}running{t.reset} " + f"{_truncate_tool_activity_label(active_tool, label_width)}" + ) for task in background_tasks: if task.get("status") == "running": - parts.append(f"{ICON_BG} {t.progress}bg{t.reset} {task.get('label', 'task')}") + label = _truncate_tool_activity_label(str(task.get("label", "task")), label_width) + parts.append(f"{ICON_BG} {t.progress}bg{t.reset} {label}") if not parts and not recent_tools: parts.append(f"{t.subtle}{ICON_DOT} idle{t.reset}") else: for tool in recent_tools[-3:]: + label = _truncate_tool_activity_label(str(tool.get("name", "tool")), label_width) if tool.get("status") == "success": - parts.append(f"{t.assistant}{ICON_SUCCESS} {tool.get('name', 'tool')}{t.reset}") + parts.append(f"{t.assistant}{ICON_SUCCESS} {label}{t.reset}") else: - parts.append(f"{t.tool_error}{ICON_ERROR} {tool.get('name', 'tool')}{t.reset}") + parts.append(f"{t.tool_error}{ICON_ERROR} {label}{t.reset}") return f"{ICON_TOOL} {t.dim}tools{t.reset} " + f" {t.subtle}{ICON_DOT}{t.reset} ".join(parts) +def _build_footer_right( + *, + tools_enabled: bool, + skills_enabled: bool, + background_tasks: list[dict[str, Any]], + compact: bool, +) -> str: + t = theme() + bg_count = len(background_tasks) + tools_indicator = f"{t.assistant}{ICON_SUCCESS}{t.reset}" if tools_enabled else f"{t.tool_error}{ICON_ERROR}{t.reset}" + skills_indicator = f"{t.assistant}{ICON_SUCCESS}{t.reset}" if skills_enabled else f"{t.tool_error}{ICON_ERROR}{t.reset}" + + if compact: + parts: list[str] = [] + if bg_count: + parts.append(f"{ICON_BG} {t.progress}{bg_count}{t.reset}") + parts.append(f"{ICON_TOOL} {tools_indicator}") + parts.append(f"{ICON_SKILL} {skills_indicator}") + return " ".join(parts) + + bg_info = "" + if bg_count: + bg_info = f" {ICON_BG} {t.progress}{bg_count} bg{t.reset} {t.subtle}|{t.reset}" + return ( + f"{bg_info} {ICON_TOOL} {t.subtle}tools{t.reset} {tools_indicator}" + f" {t.subtle}|{t.reset} {ICON_SKILL} {t.subtle}skills{t.reset} {skills_indicator}" + ) + + def render_footer_bar( status: str | None, tools_enabled: bool, @@ -488,27 +542,33 @@ def render_footer_bar( background_tasks: list[dict[str, Any]] | None = None, ) -> str: """Single-line footer bar.""" - t = theme() if background_tasks is None: background_tasks = [] width, _ = _cached_terminal_size() left = render_status_line(status) + min_gap = 1 - bg_info = "" - if background_tasks: - bg_info = f" {ICON_BG} {t.progress}{len(background_tasks)} bg{t.reset} {t.subtle}│{t.reset}" + right = _build_footer_right( + tools_enabled=tools_enabled, + skills_enabled=skills_enabled, + background_tasks=background_tasks, + compact=False, + ) + if string_display_width(left) + min_gap + string_display_width(right) > width: + right = _build_footer_right( + tools_enabled=tools_enabled, + skills_enabled=skills_enabled, + background_tasks=background_tasks, + compact=True, + ) - tools_indicator = f"{t.assistant}{ICON_SUCCESS}{t.reset}" if tools_enabled else f"{t.tool_error}{ICON_ERROR}{t.reset}" - skills_indicator = f"{t.assistant}{ICON_SUCCESS}{t.reset}" if skills_enabled else f"{t.tool_error}{ICON_ERROR}{t.reset}" + available_left = max(10, width - string_display_width(right) - min_gap) + if string_display_width(left) > available_left: + left = truncate_plain(left, available_left) - right = ( - f"{bg_info} {ICON_TOOL} {t.subtle}tools{t.reset} {tools_indicator}" - f" {t.subtle}│{t.reset} {ICON_SKILL} {t.subtle}skills{t.reset} {skills_indicator}" - ) - gap = max(1, width - string_display_width(left) - string_display_width(right)) + gap = max(min_gap, width - string_display_width(left) - string_display_width(right)) return f"{left}{' ' * gap}{right}" - def render_slash_menu(commands: list[Any], selected_index: int) -> str: """Render slash command menu with highlight.""" t = theme() diff --git a/py-src/minicode/tui/input_handler.py b/py-src/minicode/tui/input_handler.py index 1631edc..daeaf23 100644 --- a/py-src/minicode/tui/input_handler.py +++ b/py-src/minicode/tui/input_handler.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations from collections import defaultdict import logging import os @@ -10,17 +10,18 @@ from minicode.tui.state import ScreenState, TtyAppArgs from minicode.cli_commands import try_handle_local_command, find_matching_slash_commands from minicode.agent_loop import run_agent_turn -from minicode.context_manager import save_context_state +import minicode.context_manager as context_manager_module from minicode.history import save_history_entries from minicode.local_tool_shortcuts import parse_local_tool_shortcut from minicode.prompt import build_system_prompt from minicode.tooling import ToolContext from minicode.tui.navigation import _scroll_pending_approval_by, _toggle_pending_approval_expand, _move_pending_approval_selection, _scroll_transcript_by, _jump_transcript_to_edge, _history_up, _history_down, _get_visible_commands from minicode.tui.chrome import _cached_terminal_size -from minicode.tui.tool_helpers import _summarize_tool_input, _is_file_edit_tool, _extract_path_from_tool_input, _summarize_collapsed_tool_body +from minicode.tui.tool_helpers import _record_recent_tool, _summarize_tool_input, _is_file_edit_tool, _extract_path_from_tool_input, _summarize_collapsed_tool_body from minicode.tui.tool_lifecycle import _push_transcript_entry, _update_tool_entry, _update_transcript_entry, _append_to_transcript_entry, _collapse_tool_entry, _finalize_dangling_running_tools, _get_running_tool_entries, _schedule_tool_auto_collapse logger = logging.getLogger("minicode.input_handler") +save_context_state = getattr(context_manager_module, "save_context_state", None) # Cross-platform raw mode stdin # --------------------------------------------------------------------------- @@ -54,6 +55,58 @@ } +def _record_tool_entry_start( + pending_tool_started_at: dict[int, float], + entry_id: int, + *, + started_at: float | None = None, +) -> None: + """Record the start time for one concrete transcript tool entry.""" + + pending_tool_started_at[entry_id] = time.monotonic() if started_at is None else started_at + + +def _consume_tool_entry_elapsed( + pending_tool_started_at: dict[int, float], + entry_id: int | None, + *, + finished_at: float | None = None, +) -> str: + """Return a compact elapsed-time suffix for a completed tool entry.""" + + if entry_id is None: + return "" + + started_at = pending_tool_started_at.pop(entry_id, None) + if started_at is None: + return "" + + elapsed_secs = (time.monotonic() if finished_at is None else finished_at) - started_at + if elapsed_secs <= 1: + return "" + return f" ({elapsed_secs:.1f}s)" + + +def _build_tool_display_output(output: str, is_error: bool) -> str: + """Build terminal-friendly tool result text with short recovery hints.""" + + if not is_error: + return output + + suggestions: list[str] = [] + output_lower = output.lower() + if "not found" in output_lower or "no such file" in output_lower: + suggestions.append("Hint: file not found. Try /ls to inspect available paths.") + elif "permission" in output_lower or "denied" in output_lower: + suggestions.append("Hint: permission denied. Check file access rights.") + elif "syntax" in output_lower or "error" in output_lower: + suggestions.append("Hint: the command failed. Review the output and fix the issue before retrying.") + + if suggestions: + return f"ERROR: {output}\n\n" + "\n".join(suggestions) + return f"ERROR: {output}" + + def _win_read_one_key() -> str: """Read one logical key from Windows msvcrt, translating special keys into ANSI escape sequences. @@ -235,10 +288,7 @@ def _execute_tool_shortcut( tool_input, context=ToolContext(cwd=args.cwd, permissions=args.permissions), ) - state.recent_tools.append({ - "name": tool_name, - "status": "success" if result.ok else "error", - }) + _record_recent_tool(state, tool_name, "success" if result.ok else "error") output = result.output if result.ok else f"ERROR: {result.output}" _update_tool_entry(state, entry_id, "success" if result.ok else "error", output) _collapse_tool_entry(state, entry_id, _summarize_collapsed_tool_body(output)) @@ -384,6 +434,7 @@ def _handle_input( rerender() pending_tool_entries: dict[str, list[int]] = defaultdict(list) + pending_tool_started_at: dict[int, float] = {} aggregated_edit_by_key: dict[str, AggregatedEditProgress] = {} aggregated_edit_by_entry_id: dict[int, AggregatedEditProgress] = {} @@ -440,9 +491,33 @@ def on_progress_message(content: str) -> None: state.transcript_scroll_offset = 0 rerender() + def _refresh_tool_status() -> None: + running_tools = { + tool_name: len(entry_ids) + for tool_name, entry_ids in pending_tool_entries.items() + if entry_ids + } + total_running = sum(running_tools.values()) + if total_running == 0: + state.status = None + state.active_tool = None + state.tool_start_time = None + return + + if len(running_tools) == 1: + tool_name, count = next(iter(running_tools.items())) + if count == 1: + state.status = f"Running {tool_name}..." + state.active_tool = tool_name + else: + state.status = f"{count} {tool_name} task(s) running..." + state.active_tool = f"{count} {tool_name} task(s)" + return + + state.status = f"{total_running} tool(s) running..." + state.active_tool = f"{total_running} tool(s)" + def on_tool_start(tool_name: str, tool_input: Any) -> None: - state.status = f"Running {tool_name}..." - state.active_tool = tool_name state.tool_start_time = time.monotonic() # 记录工具启动时间 target_path = _extract_path_from_tool_input(tool_input) @@ -490,19 +565,15 @@ def on_tool_start(tool_name: str, tool_input: Any) -> None: ) pending_tool_entries[tool_name].append(entry_id) + _record_tool_entry_start(pending_tool_started_at, entry_id) + _refresh_tool_status() state.transcript_scroll_offset = 0 rerender() def on_tool_result(tool_name: str, output: str, is_error: bool) -> None: - # 计算并显示工具执行时间 - elapsed = "" - if state.tool_start_time is not None: - elapsed_secs = time.monotonic() - state.tool_start_time - if elapsed_secs > 1: - elapsed = f" ({elapsed_secs:.1f}s)" - pending = pending_tool_entries.get(tool_name, []) entry_id = pending.pop(0) if pending else None + elapsed = _consume_tool_entry_elapsed(pending_tool_started_at, entry_id) if entry_id is not None: aggregated = aggregated_edit_by_entry_id.get(entry_id) if aggregated and aggregated.tool_name == tool_name: @@ -512,10 +583,12 @@ def on_tool_result(tool_name: str, output: str, is_error: bool) -> None: aggregated.last_output = output done = aggregated.completed >= aggregated.total if done: - state.recent_tools.append({ - "name": f"{tool_name} x{aggregated.total}", - "status": "error" if aggregated.errors > 0 else "success", - }) + _record_recent_tool( + state, + tool_name, + "error" if aggregated.errors > 0 else "success", + display_name=f"{tool_name} x{aggregated.total}{elapsed}", + ) body = ( "\n".join([ f"Aggregated {tool_name} for {aggregated.path}", @@ -536,28 +609,15 @@ def on_tool_result(tool_name: str, output: str, is_error: bool) -> None: aggregated_edit_by_entry_id.pop(entry_id, None) aggregated_edit_by_key.pop(f"{tool_name}:{aggregated.path}", None) else: - state.recent_tools.append({ - "name": tool_name, - "status": "error" if is_error else "success", - }) + _record_recent_tool( + state, + tool_name, + "error" if is_error else "success", + display_name=f"{tool_name}{elapsed}", + ) # 错误恢复引导 - display_output = output - if is_error: - suggestions = [] - output_lower = output.lower() - if "not found" in output_lower or "no such file" in output_lower: - suggestions.append("💡 File not found. Try /ls to see available files") - elif "permission" in output_lower or "denied" in output_lower: - suggestions.append("💡 Permission denied. Check file access rights") - elif "syntax" in output_lower or "error" in output_lower: - suggestions.append("💡 Error occurred. Review the output and fix issues") - - if suggestions: - display_output = f"ERROR: {output}\n\n" + "\n".join(suggestions) - else: - display_output = f"ERROR: {output}" - + display_output = _build_tool_display_output(output, is_error) _update_tool_entry( state, entry_id, @@ -571,12 +631,7 @@ def on_tool_result(tool_name: str, output: str, is_error: bool) -> None: rerender, ) - state.active_tool = None - remaining = sum(len(v) for v in pending_tool_entries.values()) - if remaining > 0: - state.status = f"{remaining} tool(s) still running..." - else: - state.status = None + _refresh_tool_status() state.transcript_scroll_offset = 0 rerender() @@ -607,7 +662,8 @@ def _run_agent_background(): ) if args.context_manager is not None: args.context_manager.messages = next_messages - save_context_state(args.context_manager) + if save_context_state is not None: + save_context_state(args.context_manager) with agent_thread_lock: agent_result["messages"] = next_messages except Exception as e: @@ -634,3 +690,4 @@ def _run_agent_background(): # --------------------------------------------------------------------------- + diff --git a/py-src/minicode/tui/tool_helpers.py b/py-src/minicode/tui/tool_helpers.py index 005c260..39eb73b 100644 --- a/py-src/minicode/tui/tool_helpers.py +++ b/py-src/minicode/tui/tool_helpers.py @@ -60,6 +60,45 @@ def _summarize_tool_input(tool_name: str, tool_input: Any) -> str: return _truncate_for_display(repr(tool_input)) +def _record_recent_tool( + state_obj: Any, + tool_name: str, + status: str, + *, + display_name: str | None = None, + max_items: int = 12, +) -> None: + recent_tools = getattr(state_obj, "recent_tools", None) + if recent_tools is None: + return + + label = display_name or tool_name + if recent_tools: + last = recent_tools[-1] + if last.get("tool") == tool_name and last.get("status") == status: + count = int(last.get("count", 1)) + 1 + last["count"] = count + last["name"] = f"{tool_name} x{count}" + else: + recent_tools.append({ + "tool": tool_name, + "name": label, + "status": status, + "count": 1, + }) + else: + recent_tools.append({ + "tool": tool_name, + "name": label, + "status": status, + "count": 1, + }) + + overflow = len(recent_tools) - max_items + if overflow > 0: + del recent_tools[:overflow] + + def _is_file_edit_tool(tool_name: str) -> bool: return tool_name in ("edit_file", "patch_file", "modify_file", "write_file") @@ -105,7 +144,7 @@ def _mark_unfinished_tools(state_obj: Any) -> int: entry.collapsed = False entry.collapsedSummary = None entry.collapsePhase = None - state_obj.recent_tools.append({"name": entry.toolName or "unknown", "status": "error"}) + _record_recent_tool(state_obj, entry.toolName or "unknown", "error") count += 1 if hasattr(state_obj, "pending_tool_runs"): state_obj.pending_tool_runs = {} diff --git a/py-src/minicode/tui/tool_lifecycle.py b/py-src/minicode/tui/tool_lifecycle.py index 74bc398..2db22ef 100644 --- a/py-src/minicode/tui/tool_lifecycle.py +++ b/py-src/minicode/tui/tool_lifecycle.py @@ -5,7 +5,7 @@ from typing import Any, Callable from minicode.tui.state import ScreenState -from minicode.tui.tool_helpers import _summarize_collapsed_tool_body +from minicode.tui.tool_helpers import _record_recent_tool, _summarize_collapsed_tool_body from minicode.tui.types import TranscriptEntry @@ -68,7 +68,7 @@ def _mark_running_tools_as_error(state: ScreenState, message: str) -> None: entry.collapsed = False entry.collapsedSummary = None entry.collapsePhase = None - state.recent_tools.append({"name": entry.toolName or "unknown", "status": "error"}) + _record_recent_tool(state, entry.toolName or "unknown", "error") changed = True if any(e.kind == "tool" and e.status == "error" for e in state.transcript): state.active_tool = None diff --git a/py-src/minicode/tui/transcript.py b/py-src/minicode/tui/transcript.py index 0b8c6d5..5d8deac 100644 --- a/py-src/minicode/tui/transcript.py +++ b/py-src/minicode/tui/transcript.py @@ -2,6 +2,7 @@ from bisect import bisect_left from dataclasses import dataclass +import re from .chrome import ( _cached_terminal_size, @@ -23,6 +24,12 @@ # Tool output preview limits (match Rust TOOL_PREVIEW_LINES / TOOL_PREVIEW_CHARS) _TOOL_PREVIEW_LINES = 6 _TOOL_PREVIEW_CHARS = 180 +_COMPACT_TRANSCRIPT_WIDTH = 72 +_COMPACT_ASSISTANT_CHARS = 62 +_COMPACT_PROGRESS_CHARS = 58 +_COMPACT_TOOL_CHARS = 54 +_COMPACT_PATH_TOKEN_CHARS = 32 +_COMPACT_TABLE_COLUMNS = 3 def _indent_block(text: str, prefix: str = " ") -> str: @@ -30,6 +37,1357 @@ def _indent_block(text: str, prefix: str = " ") -> str: return "\n".join(prefix + line for line in text.split("\n")) +def _transcript_render_columns() -> int: + cols, _ = _cached_terminal_size() + return max(40, cols) + + +def _should_compact_transcript_preview() -> bool: + return _transcript_render_columns() <= _COMPACT_TRANSCRIPT_WIDTH + + +def _center_ellipsize(text: str, max_chars: int) -> str: + if len(text) <= max_chars: + return text + if max_chars <= 5: + return text[:max_chars] + + visible = max_chars - 3 + left = visible // 2 + right = visible - left + return f"{text[:left]}...{text[-right:]}" + + +def _tail_weighted_ellipsize(text: str, max_chars: int) -> str: + if len(text) <= max_chars: + return text + if max_chars <= 5: + return text[:max_chars] + + visible = max_chars - 3 + tail = max(visible // 2, int(visible * 0.65)) + tail = min(visible - 1, tail) + head = visible - tail + return f"{text[:head].rstrip()}...{text[-tail:]}" + + +def _ellipsize_structured_followup(text: str, max_chars: int) -> str: + if len(text) <= max_chars: + return text + if ": " not in text: + return _tail_weighted_ellipsize(text, max_chars) + + label, detail = text.split(": ", 1) + prefix = f"{label}: " + if len(prefix) >= max_chars: + return _tail_weighted_ellipsize(prefix.rstrip(), max_chars) + + detail_budget = max(6, max_chars - len(prefix)) + if label == "table" and "|" in detail: + cells = [cell.strip() for cell in detail.split("|")] + cells = [cell for cell in cells if cell] + if cells: + first = cells[0] + last = cells[-1] + candidates = [] + if len(cells) >= 2: + candidates.extend( + [ + f"{first} | ... | {last}", + f"{first} |...| {last}", + f"{first}|...|{last}", + f"{first} |...|{last}", + f"{first}|...| {last}", + f"{first}...{last}", + f"{first} | {last}", + f"... | {last}", + ] + ) + candidates.append(first) + for candidate in candidates: + if len(candidate) <= detail_budget: + return f"{prefix}{candidate}" + if label == "quote": + compact_quote = _compact_quote_detail(detail, detail_budget) + if compact_quote: + return f"{prefix}{compact_quote}" + if label == "list": + compact_list = _compact_list_detail(detail, detail_budget) + if compact_list: + return f"{prefix}{compact_list}" + if label.endswith("code"): + compact_code = _compact_code_detail(detail, detail_budget) + if compact_code: + return f"{prefix}{compact_code}" + return f"{prefix}{_tail_weighted_ellipsize(detail, detail_budget)}" + + +def _compact_plain_followup_text( + text: str, max_chars: int, *, prefer_tail: bool = False +) -> str: + if len(text) <= max_chars: + return text + + normalized = " ".join(text.split()) + tokens = [token for token in normalized.split() if token] + if tokens: + boundary_words = { + "should", + "stays", + "stay", + "remains", + "remain", + "is", + "are", + "was", + "were", + "can", + "could", + "may", + "might", + "will", + "would", + "as", + "before", + "after", + "while", + "during", + "when", + "to", + } + leading_words = {"use", "keep", "preserve", "retain"} + articles = {"the", "a", "an"} + start_index = 0 + if tokens[0].lower() in leading_words and len(tokens) > 1: + start_index = 1 + if start_index < len(tokens) and tokens[start_index].lower() in articles: + start_index += 1 + + subject_tokens: list[str] = [] + single_token_candidate: str | None = None + for token in tokens[start_index:]: + stripped = token.strip(",;:()[]{}") + if not stripped: + continue + if stripped.lower() in boundary_words: + break + subject_tokens.append(stripped) + if len(subject_tokens) >= 5: + break + + if subject_tokens: + if len(subject_tokens) == 1: + single_token_candidate = subject_tokens[0] + else: + tail_candidates: list[str] = [] + if len(subject_tokens) == 2: + if prefer_tail: + tail_candidates.append(subject_tokens[-1]) + tail_candidates.append(" ".join(subject_tokens)) + tail_candidates.append(subject_tokens[0]) + else: + tail_candidates.append(" ".join(subject_tokens)) + tail_candidates.append(subject_tokens[0]) + tail_candidates.append(subject_tokens[-1]) + elif len(subject_tokens) >= 2: + tail_candidates.append(" ".join(subject_tokens[-2:])) + if len(subject_tokens) >= 3: + tail_candidates.append(subject_tokens[len(subject_tokens) // 2]) + tail_candidates.append(subject_tokens[-1]) + if len(subject_tokens) >= 3: + tail_candidates.append(" ".join(subject_tokens[-3:])) + for tail_candidate in tail_candidates: + if len(tail_candidate) <= max_chars: + return tail_candidate + + words = normalized.split() + if len(words) <= 3: + short_candidates: list[str] = [] + if len(words) == 1: + short_candidates.append(words[0]) + elif len(words) == 2: + short_candidates.extend([normalized, words[0], words[1]]) + else: + short_candidates.extend( + [ + " ".join(words[-2:]), + words[1], + words[-1], + " ".join(words[:2]), + ] + ) + for candidate in short_candidates: + if len(candidate) <= max_chars: + return candidate + + candidate_patterns = ( + r"\bon ([^.,;]+)", + r"\bfor ([^.,;]+)", + r"\bwith ([^.,;]+)", + r"\babout ([^.,;]+)", + r"\bto ([^.,;]+)", + r"\bafter ([^.,;]+)", + ) + for pattern in candidate_patterns: + match = re.search(pattern, normalized, re.IGNORECASE) + if not match: + continue + candidate = match.group(1).strip() + for splitter in (" before ", " after ", " while ", " during ", " when "): + if splitter in candidate: + candidate = candidate.split(splitter, 1)[0].strip() + break + candidate = re.sub(r"^(?:the|a|an)\s+", "", candidate, flags=re.IGNORECASE) + if candidate and len(candidate) <= max_chars: + return candidate + + if single_token_candidate and len(single_token_candidate) <= max_chars: + return single_token_candidate + + return _center_ellipsize(normalized, max_chars) + + +def _compact_quote_detail(text: str, max_chars: int) -> str: + normalized = " ".join(text.split()) + if len(normalized) <= max_chars: + return normalized + + if "..." in normalized: + head, tail = normalized.split("...", 1) + head = head.strip() + tail = tail.strip() + if head and tail: + candidates = [] + tail_words = tail.split() + if len(tail_words) >= 2: + candidates.append(f"...{' '.join(tail_words[-2:])}") + if tail_words: + candidates.append(f"...{tail_words[-1]}") + candidates.extend( + [ + f"{head}...{tail}", + f"{head} ... {tail}", + f"...{tail}", + f"{head}...", + ] + ) + for candidate in candidates: + if len(candidate) <= max_chars: + return candidate + + words = normalized.split() + if len(words) < 2: + return _tail_weighted_ellipsize(normalized, max_chars) + + head = words[0] + for tail_count in (2, 1): + tail = " ".join(words[-tail_count:]) + candidate = f"{head}...{tail}" + if len(candidate) <= max_chars: + return candidate + candidate = f"{head} ... {tail}" + if len(candidate) <= max_chars: + return candidate + candidate = f"...{tail}" + if len(candidate) <= max_chars: + return candidate + + return _tail_weighted_ellipsize(normalized, max_chars) + + +def _compact_code_detail(text: str, max_chars: int) -> str: + stripped = text.strip() + if len(stripped) <= max_chars: + return stripped + + core, markers = _split_trailing_followup_markers(stripped) + if markers: + core_budget = max(6, max_chars - len(markers)) + compact_core = _compact_code_detail_core(core, core_budget) + candidate = f"{compact_core}{markers}" + if len(candidate) <= max_chars: + return candidate + stripped = core + + return _compact_code_detail_core(stripped, max_chars) + + +def _compact_code_detail_core(stripped: str, max_chars: int) -> str: + identifier = stripped.split("(", 1)[0].strip() + if not identifier: + return _tail_weighted_ellipsize(stripped, max_chars) + if len(identifier) <= max_chars: + return identifier + + if "_" in identifier: + parts = [part for part in identifier.split("_") if part] + if parts: + tail = parts[-1] + for head_parts in range(min(2, len(parts) - 1), 0, -1): + head = "_".join(parts[:head_parts]) + candidate = f"{head}...{tail}" + if len(candidate) <= max_chars: + return candidate + if len(tail) + 3 <= max_chars: + return f"...{tail}" + if max_chars > 3: + return f"...{tail[-(max_chars - 3):]}" + + return _tail_weighted_ellipsize(identifier, max_chars) + + +def _compact_list_detail(text: str, max_chars: int) -> str: + normalized = " ".join(text.split()) + if len(normalized) <= max_chars: + return normalized + + if "..." in normalized: + head, tail = normalized.split("...", 1) + head = head.strip() + tail = tail.strip() + if head and tail: + candidates = [ + f"{head} {tail}", + f"{head}...{tail}", + f"...{tail}", + ] + for candidate in candidates: + if len(candidate) <= max_chars: + return candidate + + words = normalized.split() + if len(words) < 2: + return _tail_weighted_ellipsize(normalized, max_chars) + + head = words[0] + tail = words[-1] + candidates = [f"{head} {tail}", f"{head}...{tail}", f"{head}..."] + if len(words) >= 3: + candidates.extend( + [ + f"{head} {words[1]}...{tail}", + f"{head} {words[1]}...", + f"{head} {words[1]}", + ] + ) + candidates.append(head) + + for candidate in candidates: + if len(candidate) <= max_chars: + return candidate + + if len(head) + 3 <= max_chars: + return f"{head}..." + if len(tail) + 3 <= max_chars: + return f"...{tail}" + + return _tail_weighted_ellipsize(normalized, max_chars) + + +def _split_trailing_followup_markers(text: str) -> tuple[str, str]: + match = re.search(r"^(.*?)(\s(?:\[[A-Za-z_]+\]\s*)+)$", text) + if not match: + return text, "" + markers = re.findall(r"\[[A-Za-z_]+\]", match.group(2)) + normalized_markers = "" + if markers: + normalized_markers = " " + " ".join(markers) + return match.group(1).rstrip(), normalized_markers + + +def _fit_trailing_followup_markers( + prefix: str, + detail_core: str, + existing_markers: str, + new_marker: str, + max_chars: int, +) -> str | None: + marker_tokens = re.findall(r"\[[A-Za-z_]+\]", existing_markers) + marker_tokens.append(f"[{new_marker}]") + + for keep_count in range(len(marker_tokens), 0, -1): + suffix = " " + " ".join(marker_tokens[-keep_count:]) + prefix_budget = max(12, max_chars - len(detail_core) - len(suffix) - 3) + compact_prefix = prefix + if len(compact_prefix) > prefix_budget: + compact_prefix = _ellipsize_structured_followup(compact_prefix, prefix_budget) + candidate = f"{compact_prefix} - {detail_core}{suffix}" + if len(candidate) <= max_chars: + return candidate + + prefix_budget = max(12, max_chars - len(detail_core) - 3) + compact_prefix = prefix + if len(compact_prefix) > prefix_budget: + compact_prefix = _ellipsize_structured_followup(compact_prefix, prefix_budget) + candidate = f"{compact_prefix} - {detail_core}" + if len(candidate) <= max_chars: + return candidate + return None + + +def _score_table_followup_candidate(text: str) -> tuple[int, int, int]: + prefix, detail = text.rsplit(" - ", 1) + marker_count = len(re.findall(r"\[[A-Za-z_]+\]", detail)) + table_detail = prefix.split(": ", 1)[1] if ": " in prefix else prefix + preserves_column_shape = int( + ("|" in table_detail and table_detail.count("|") >= 2) + or any(token in table_detail for token in (" | ... | ", " |...| ", "|...|", " |...|")) + ) + return (preserves_column_shape, marker_count, len(prefix)) + + +def _fit_table_followup_markers( + prefix: str, + detail_core: str, + existing_markers: str, + new_marker: str, + max_chars: int, +) -> str | None: + marker_tokens = re.findall(r"\[[A-Za-z_]+\]", existing_markers) + marker_tokens.append(f"[{new_marker}]") + best_candidate: str | None = None + best_score: tuple[int, int, int] | None = None + + for keep_count in range(len(marker_tokens), 0, -1): + suffix = " " + " ".join(marker_tokens[-keep_count:]) + prefix_budget = max(12, max_chars - len(detail_core) - len(suffix) - 3) + compact_prefix = prefix + if len(compact_prefix) > prefix_budget: + compact_prefix = _ellipsize_structured_followup(compact_prefix, prefix_budget) + candidate = f"{compact_prefix} - {detail_core}{suffix}" + if len(candidate) <= max_chars: + score = _score_table_followup_candidate(candidate) + if best_score is None or score > best_score: + best_candidate = candidate + best_score = score + + prefix_budget = max(12, max_chars - len(detail_core) - 3) + compact_prefix = prefix + if len(compact_prefix) > prefix_budget: + compact_prefix = _ellipsize_structured_followup(compact_prefix, prefix_budget) + candidate = f"{compact_prefix} - {detail_core}" + if len(candidate) <= max_chars: + score = _score_table_followup_candidate(candidate) + if best_score is None or score > best_score: + best_candidate = candidate + return best_candidate + + +def _compact_followup_detail_preserving_markers(text: str, max_chars: int) -> str: + core, markers = _split_trailing_followup_markers(text) + if not markers: + return _compact_plain_followup_text(text, max_chars) + if len(text) <= max_chars: + return text + + marker_budget = len(markers) + core_budget = max(6, max_chars - marker_budget) + if len(core) > core_budget: + core = _compact_plain_followup_text(core, core_budget) + return f"{core}{markers}" + + +def _compact_plain_prefix_fragment(text: str, max_chars: int) -> str: + normalized = " ".join(text.split()) + if len(normalized) <= max_chars: + return normalized + + words = [word for word in normalized.split() if word] + if len(words) >= 2: + tail_phrase = " ".join(words[-2:]) + if len(tail_phrase) <= max_chars: + return tail_phrase + if words: + if len(words[-1]) <= max_chars: + return words[-1] + if len(words[0]) <= max_chars: + return words[0] + return _compact_plain_followup_text(normalized, max_chars) + + +def _compact_code_preview_line(code_line: str) -> str: + stripped = _strip_assistant_markdown_prefix(code_line) + if not stripped: + return "" + + if match := re.match(r"(?:async\s+)?def\s+([A-Za-z_]\w*)", stripped): + return match.group(1) + if match := re.match(r"class\s+([A-Za-z_]\w*)", stripped): + return match.group(1) + if match := re.match(r"([A-Za-z_]\w*)\s*=\s*", stripped): + return f"{match.group(1)} = ..." + return stripped + + +def _is_pathlike_token(token: str) -> bool: + stripped = token.strip(",;:()[]{}") + return ( + "\\" in stripped + or "/" in stripped + or stripped.startswith(("path=", "file=", "cwd=")) + or stripped.endswith( + ( + ".py", + ".ts", + ".tsx", + ".js", + ".jsx", + ".md", + ".json", + ".yaml", + ".yml", + ".toml", + ".txt", + ) + ) + ) + + +def _compact_pathlike_tokens(text: str, max_chars: int) -> str: + tokens = text.split() + if not tokens: + return text + + token_budget = min(_COMPACT_PATH_TOKEN_CHARS, max(14, max_chars // 2)) + compacted: list[str] = [] + for token in tokens: + if _is_pathlike_token(token) and len(token) > token_budget: + compacted.append(_compact_pathlike_token(token, token_budget)) + else: + compacted.append(token) + return " ".join(compacted) + + +def _compact_pathlike_token(token: str, max_chars: int) -> str: + if len(token) <= max_chars: + return token + + leading_len = len(token) - len(token.lstrip(",;:()[]{}")) + trailing_len = len(token) - len(token.rstrip(",;:()[]{}")) + leading = token[:leading_len] + trailing = token[len(token) - trailing_len :] if trailing_len else "" + core = token[leading_len : len(token) - trailing_len if trailing_len else len(token)] + + prefix = "" + path_value = core + if "=" in core: + candidate_prefix, candidate_value = core.split("=", 1) + if candidate_prefix in {"path", "file", "cwd"}: + prefix = f"{candidate_prefix}=" + path_value = candidate_value + + if "\\" in path_value or "/" in path_value: + separator = "\\" if "\\" in path_value else "/" + segments = [segment for segment in re.split(r"[\\/]+", path_value) if segment] + basename = segments[-1] if segments else path_value + drive = "" + if segments and segments[0].endswith(":"): + drive = f"{segments[0]}{separator}" + compact = f"{prefix}{drive}...{separator}{basename}" + if len(compact) > max_chars: + basename_budget = max(8, max_chars - len(prefix) - len(drive) - 4) + compact = f"{prefix}{drive}...{separator}{_tail_weighted_ellipsize(basename, basename_budget)}" + return f"{leading}{compact}{trailing}" + + return f"{leading}{_center_ellipsize(core, max_chars - leading_len - trailing_len)}{trailing}" + + +def _compact_transcript_preview(body: str, max_chars: int) -> str: + """Return a first-screen preview that stays readable on narrow terminals.""" + if not body: + return body + + lines = body.splitlines() + summary = "" + summary_index = 0 + for i, line in enumerate(lines): + stripped = line.strip() + if stripped: + summary = stripped + summary_index = i + break + if not summary and lines: + summary = lines[0].strip() + + more_content = any(line.strip() for line in lines[summary_index + 1 :]) + if not summary: + return "..." + + summary = _compact_pathlike_tokens(summary, max_chars) + has_pathlike = any(_is_pathlike_token(token) for token in summary.split()) + suffix = " ..." + truncated = False + if len(summary) > max_chars: + summary = ( + _tail_weighted_ellipsize(summary, max_chars) + if has_pathlike + else _center_ellipsize(summary, max_chars) + ) + truncated = True + elif more_content: + if len(summary) + len(suffix) > max_chars: + summary = summary[: max_chars - len(suffix)].rstrip() + summary = f"{summary}{suffix}" + truncated = True + + if not truncated and len(lines) > 1: + summary = f"{summary}{suffix}" + + return summary + + +def _strip_assistant_markdown_prefix(line: str) -> str: + stripped = line.strip() + if not stripped: + return stripped + + stripped = stripped.lstrip("#").strip() if stripped.startswith("#") else stripped + if stripped.startswith("> "): + stripped = stripped[2:].strip() + stripped = re.sub(r"^[-*+]\s+", "", stripped) + stripped = re.sub(r"^\d+\.\s+", "", stripped) + stripped = re.sub(r"^- \[[ xX]\]\s+", "", stripped) + return stripped.strip() + + +def _is_list_line(line: str) -> bool: + stripped = line.lstrip() + return bool( + re.match(r"^(?:[-*+]\s+|\d+\.\s+|- \[[ xX]\]\s+)", stripped) + ) + + +def _is_table_row(line: str) -> bool: + stripped = line.strip() + return stripped.startswith("|") and stripped.endswith("|") and stripped.count("|") >= 2 + + +def _is_table_separator(line: str) -> bool: + stripped = line.strip().strip("|").strip() + if not stripped: + return False + cells = [cell.strip() for cell in stripped.split("|")] + return bool(cells) and all(cell and set(cell) <= {":", "-"} for cell in cells) + + +def _table_preview_summary(header_line: str) -> str: + cells = [cell.strip() for cell in header_line.strip().strip("|").split("|")] + cells = [cell for cell in cells if cell] + if not cells: + return "table" + preview_cells = cells[:_COMPACT_TABLE_COLUMNS] + summary = " | ".join(preview_cells) + return f"table: {summary}" + + +def _list_preview_summary(line: str) -> str: + item = _strip_assistant_markdown_prefix(line) + if _should_compact_transcript_preview(): + item_budget = min(_COMPACT_ASSISTANT_CHARS - 6, max(32, _transcript_render_columns() - 8)) + if len(item) > item_budget: + item = f"{item[: item_budget - 3].rstrip()}..." + return f"list: {item}" + + +def _first_list_preview(meaningful_lines: list[tuple[int, str]]) -> str | None: + if not meaningful_lines: + return None + _, first_line = meaningful_lines[0] + if not _is_list_line(first_line): + return None + + list_count = 0 + for _, item_line in meaningful_lines: + stripped = item_line.strip() + if _is_list_line(item_line): + list_count += 1 + continue + if stripped: + break + summary = _list_preview_summary(first_line.strip()) + return f"{summary}\n..." if list_count > 1 else summary + + +def _first_quote_preview(meaningful_lines: list[tuple[int, str]]) -> str | None: + quote_lines = [] + for _, quote_line in meaningful_lines: + stripped_quote = quote_line.strip() + if not stripped_quote.startswith(">"): + break + normalized = _strip_assistant_markdown_prefix(stripped_quote) + if normalized: + quote_lines.append(normalized) + if not quote_lines: + return None + return ( + f"quote: {quote_lines[0]}\n..." + if len(quote_lines) > 1 + else f"quote: {quote_lines[0]}" + ) + + +def _find_followup_table_summary(lines: list[str], start_index: int) -> str | None: + for idx in range(start_index + 1, len(lines) - 1): + row = lines[idx].strip() + if _is_table_row(row) and _is_table_separator(lines[idx + 1]): + return _table_preview_summary(row) + return None + + +def _find_followup_quote_summary(lines: list[str], start_index: int) -> str | None: + for idx in range(start_index + 1, len(lines)): + stripped = lines[idx].strip() + if not stripped: + continue + if stripped.startswith(">"): + normalized = _strip_assistant_markdown_prefix(stripped) + if normalized: + return f"quote: {normalized}" + if stripped and not stripped.startswith((">", "```")): + continue + return None + + +def _find_followup_list_summary(lines: list[str], start_index: int) -> str | None: + for idx in range(start_index + 1, len(lines)): + stripped = lines[idx].strip() + if not stripped: + continue + if _is_list_line(lines[idx]): + return _list_preview_summary(stripped) + return None + + +def _find_followup_code_summary(lines: list[str], start_index: int) -> str | None: + for idx in range(start_index + 1, len(lines)): + stripped = lines[idx].strip() + if not stripped: + continue + if not stripped.startswith("```"): + continue + + lang = stripped[3:].strip() + for code_idx in range(idx + 1, len(lines)): + code_line = lines[code_idx].strip() + if not code_line: + continue + if code_line.startswith("```"): + break + code_summary = _compact_code_preview_line(code_line) + if code_summary: + label = f"{lang} code" if lang else "code" + return f"{label}: {code_summary}" + return f"{lang} code" if lang else "code" + return None + + +def _find_followup_candidates_with_end_indices( + lines: list[str], start_index: int +) -> list[tuple[str, str, int]]: + candidates: list[tuple[str, str, int]] = [] + seen_kinds: set[str] = set() + idx = start_index + 1 + + while idx < len(lines): + stripped = lines[idx].strip() + if not stripped: + idx += 1 + continue + + if "code" not in seen_kinds and stripped.startswith("```"): + lang = stripped[3:].strip() + code_summary = f"{lang} code" if lang else "code" + block_end_index = idx + idx += 1 + while idx < len(lines): + code_line = lines[idx].strip() + if code_line.startswith("```"): + block_end_index = idx + break + compact = _compact_code_preview_line(code_line) + if compact and code_summary == (f"{lang} code" if lang else "code"): + label = f"{lang} code" if lang else "code" + code_summary = f"{label}: {compact}" + block_end_index = idx + idx += 1 + candidates.append(("code", code_summary, block_end_index)) + seen_kinds.add("code") + elif "table" not in seen_kinds and idx + 1 < len(lines) and _is_table_row(stripped) and _is_table_separator(lines[idx + 1]): + block_end_index = idx + 1 + scan_index = idx + 2 + while scan_index < len(lines): + scan_stripped = lines[scan_index].strip() + if _is_table_row(scan_stripped): + block_end_index = scan_index + scan_index += 1 + continue + if scan_stripped: + break + scan_index += 1 + candidates.append(("table", _table_preview_summary(stripped), block_end_index)) + seen_kinds.add("table") + idx += 1 + elif "quote" not in seen_kinds and stripped.startswith(">"): + normalized = _strip_assistant_markdown_prefix(stripped) + block_end_index = idx + if normalized: + candidates.append(("quote", f"quote: {normalized}", block_end_index)) + seen_kinds.add("quote") + while idx + 1 < len(lines) and lines[idx + 1].strip().startswith(">"): + idx += 1 + block_end_index = idx + if normalized: + candidates[-1] = ("quote", f"quote: {normalized}", block_end_index) + elif "list" not in seen_kinds and _is_list_line(lines[idx]): + block_end_index = idx + while idx + 1 < len(lines): + next_stripped = lines[idx + 1].strip() + if not next_stripped: + break + if not _is_list_line(lines[idx + 1]): + break + idx += 1 + block_end_index = idx + candidates.append(("list", _list_preview_summary(stripped), block_end_index)) + seen_kinds.add("list") + + idx += 1 + + return candidates + + +def _find_followup_candidates(lines: list[str], start_index: int) -> list[tuple[str, str]]: + return [ + (kind, summary) + for kind, summary, _ in _find_followup_candidates_with_end_indices(lines, start_index) + ] + + +def _find_followup_plain_text_summary_with_index( + lines: list[str], start_index: int +) -> tuple[str, int] | None: + candidates = _find_followup_plain_text_summaries_with_indices(lines, start_index) + if candidates: + return candidates[0] + return None + + +def _find_followup_plain_text_summaries_with_indices( + lines: list[str], start_index: int +) -> list[tuple[str, int]]: + idx = start_index + 1 + candidates: list[tuple[str, int]] = [] + + while idx < len(lines): + stripped = lines[idx].strip() + if not stripped: + idx += 1 + continue + + if stripped.startswith("```"): + idx += 1 + while idx < len(lines): + if lines[idx].strip().startswith("```"): + break + idx += 1 + idx += 1 + continue + + if _is_table_row(stripped): + idx += 1 + continue + + if stripped.startswith(">") or _is_list_line(lines[idx]): + idx += 1 + continue + + normalized = _strip_assistant_markdown_prefix(stripped) + if normalized: + candidates.append((normalized, idx)) + idx += 1 + + return candidates + + +def _find_followup_plain_text_summary(lines: list[str], start_index: int) -> str | None: + match = _find_followup_plain_text_summary_with_index(lines, start_index) + if match: + return match[0] + return None + + +def _finalize_primary_assistant_preview( + summary: str, + lines: list[str], + start_index: int, + *, + force_more: bool = False, + closing_text_summary: str | None = None, +) -> str: + followup_candidates = _find_followup_candidates_with_end_indices(lines, start_index) + plain_followup_candidates = _find_followup_plain_text_summaries_with_indices( + lines, start_index + ) + plain_followup = plain_followup_candidates[0] if plain_followup_candidates else None + remaining_followups = followup_candidates + + if followup_candidates and plain_followup: + first_followup_summary, first_followup_index = plain_followup + first_marker_kind, first_marker_summary, first_marker_end_index = followup_candidates[0] + preferred_plain_followup = first_followup_summary + if len(plain_followup_candidates) > 1: + preferred_plain_followup = plain_followup_candidates[-1][0] + if first_marker_end_index < first_followup_index: + summary = _compose_assistant_followup_summary(summary, first_marker_summary) + summary = _compose_assistant_followup_summary(summary, preferred_plain_followup) + remaining_followups = followup_candidates[1:] + else: + summary = _compose_assistant_followup_summary(summary, preferred_plain_followup) + elif plain_followup: + summary = _compose_assistant_followup_summary(summary, plain_followup[0]) + elif closing_text_summary: + summary = _compose_assistant_followup_summary(summary, closing_text_summary) + + for marker_kind, _, _ in remaining_followups: + summary = _append_assistant_followup_marker(summary, marker_kind) + + if force_more or remaining_followups: + inline_more_budget = max(16, _transcript_render_columns() - 2) + if len(summary) + 4 <= inline_more_budget: + return f"{summary} ..." + return f"{summary}\n..." + return summary + + +def _compose_assistant_followup_summary(summary: str, followup: str) -> str: + compact_budget = _COMPACT_ASSISTANT_CHARS + if " code:" in followup: + compact_budget = min(compact_budget, max(44, _transcript_render_columns() - 6)) + + combined = f"{summary} - {followup}" + if len(combined) <= compact_budget: + return combined + + if ": " in summary and ": " in followup: + followup_budget = max(20, compact_budget - len(summary) - 3) + if len(followup) > followup_budget: + followup = _ellipsize_structured_followup(followup, followup_budget) + combined = f"{summary} - {followup}" + if len(combined) <= compact_budget: + return combined + + followup_kind = None + if "code:" in followup: + followup_kind = "code" + elif followup.startswith("table:"): + followup_kind = "table" + elif followup.startswith("quote:"): + followup_kind = "quote" + elif followup.startswith("list:"): + followup_kind = "list" + + if followup_kind: + reduced = f"{summary} [{followup_kind}]" + if len(reduced) <= compact_budget: + return reduced + + summary_budget = max(20, compact_budget - len(followup) - 3) + if len(summary) > summary_budget: + summary = _ellipsize_structured_followup(summary, summary_budget) + return f"{summary} - {followup}" + + if ": " in summary and ": " not in followup: + followup_budget = max(16, compact_budget - len(summary) - 3) + if len(followup) > followup_budget: + followup = _compact_plain_followup_text(followup, followup_budget) + combined = f"{summary} - {followup}" + if len(combined) <= compact_budget: + return combined + + if " - " in summary: + first_segment, second_segment = summary.split(" - ", 1) + if ": " in first_segment and ": " in second_segment: + second_budget = max(10, compact_budget - len(first_segment) - len(followup) - 6) + reduced_second = second_segment + if len(reduced_second) > second_budget: + reduced_second = _ellipsize_structured_followup(reduced_second, second_budget) + reduced = f"{first_segment} - {reduced_second} - {followup}" + if len(reduced) <= compact_budget: + return reduced + + second_kind = None + if "code:" in second_segment: + second_kind = "code" + elif second_segment.startswith("table:"): + second_kind = "table" + elif second_segment.startswith("quote:"): + second_kind = "quote" + elif second_segment.startswith("list:"): + second_kind = "list" + + if second_kind: + reduced = f"{first_segment} [{second_kind}] - {followup}" + if len(reduced) <= compact_budget: + return reduced + first_budget = max( + 14, + compact_budget - len(f" [{second_kind}]") - len(followup) - 3, + ) + compact_first = first_segment + if len(compact_first) > first_budget: + compact_first = _ellipsize_structured_followup( + compact_first, first_budget + ) + reduced = f"{compact_first} [{second_kind}] - {followup}" + if len(reduced) <= compact_budget: + return reduced + + plain_prefix, structured_prefix = summary.split(" - ", 1) + if ": " not in plain_prefix and ": " in structured_prefix: + reduced_followup_budget = max(12, compact_budget - len(structured_prefix) - 3) + reduced_followup = followup + if len(reduced_followup) > reduced_followup_budget: + reduced_followup = _compact_plain_followup_text( + reduced_followup, reduced_followup_budget + ) + reduced = f"{structured_prefix} - {reduced_followup}" + if len(reduced) <= compact_budget: + return reduced + + structured_budget = max(20, compact_budget - len(reduced_followup) - 3) + compact_structured = structured_prefix + if len(compact_structured) > structured_budget: + compact_structured = _ellipsize_structured_followup( + compact_structured, structured_budget + ) + reduced = f"{compact_structured} - {reduced_followup}" + if len(reduced) <= compact_budget: + return reduced + return combined + + summary_budget = max(16, compact_budget - len(followup) - 3) + if len(summary) > summary_budget: + if ": " in followup and ": " not in summary: + summary = _compact_plain_followup_text(summary, summary_budget) + else: + summary = _tail_weighted_ellipsize(summary, summary_budget) + return f"{summary} - {followup}" + + +def _append_assistant_followup_marker(summary: str, marker: str) -> str: + compact_budget = min(_COMPACT_ASSISTANT_CHARS - 6, max(40, _transcript_render_columns() - 8)) + compact_marker = f" [{marker}]" + combined = f"{summary}{compact_marker}" + if len(combined) <= compact_budget: + return combined + + if " - " in summary: + prefix, detail = summary.rsplit(" - ", 1) + if ": " in prefix and ": " not in detail: + structured_budget = min(_COMPACT_ASSISTANT_CHARS, max(44, _transcript_render_columns() - 3)) + detail_core, detail_markers = _split_trailing_followup_markers(detail) + if prefix.startswith("table:"): + fitted_table = _fit_table_followup_markers( + prefix, + detail_core, + detail_markers, + marker, + structured_budget, + ) + if fitted_table and not detail_markers: + return fitted_table + if detail_markers and detail_core: + prioritized_detail = f"{detail_core}{detail_markers}" + prefix_budget = max( + 12, + structured_budget - len(prioritized_detail) - len(compact_marker) - 3, + ) + compact_prefix = prefix + if len(compact_prefix) > prefix_budget: + compact_prefix = _ellipsize_structured_followup( + compact_prefix, prefix_budget + ) + prioritized = ( + f"{compact_prefix} - {prioritized_detail}{compact_marker}" + ) + prioritized_candidate = ( + prioritized if len(prioritized) <= structured_budget else None + ) + if prefix.startswith("table:"): + fitted_markers = _fit_table_followup_markers( + prefix, + detail_core, + detail_markers, + marker, + structured_budget, + ) + else: + fitted_markers = _fit_trailing_followup_markers( + prefix, + detail_core, + detail_markers, + marker, + structured_budget, + ) + if prioritized_candidate and fitted_markers: + if prefix.startswith("table:") and _score_table_followup_candidate( + fitted_markers + ) > _score_table_followup_candidate(prioritized_candidate): + return fitted_markers + return prioritized_candidate + if prioritized_candidate: + return prioritized_candidate + if fitted_markers: + return fitted_markers + detail_budget = max(14, structured_budget - len(compact_marker) - len(prefix) - 3) + if len(detail) > detail_budget: + detail = _compact_followup_detail_preserving_markers( + detail, detail_budget + ) + combined = f"{prefix} - {detail}{compact_marker}" + if len(combined) <= structured_budget: + return combined + + if ": " in prefix: + prefix_label, prefix_detail = prefix.split(": ", 1) + if prefix_label.endswith("code"): + cleaned_prefix = prefix.split(" - ", 1)[1] if " - " in prefix else prefix + preserved_detail = f"{cleaned_prefix} - {detail}{compact_marker}" + if len(preserved_detail) <= structured_budget: + return preserved_detail + + prefix_detail_budget = max( + 12, + structured_budget - len(compact_marker) - len(prefix_label) - 2, + ) + compact_prefix_detail = _compact_code_detail( + prefix_detail, prefix_detail_budget + ) + reduced = f"{prefix_label}: {compact_prefix_detail}{compact_marker}" + if len(reduced) <= structured_budget: + return reduced + + if " - " in prefix: + plain_prefix, structured_prefix = prefix.split(" - ", 1) + structured_combined = f"{plain_prefix} - {structured_prefix} - {detail}{compact_marker}" + if len(structured_combined) > structured_budget: + plain_budget = max( + 10, + structured_budget + - len(structured_prefix) + - len(detail) + - len(compact_marker) + - 6, + ) + if len(plain_prefix) > plain_budget: + if ": " in plain_prefix: + plain_prefix = _ellipsize_structured_followup( + plain_prefix, plain_budget + ) + else: + plain_prefix = _compact_plain_prefix_fragment( + plain_prefix, plain_budget + ) + structured_combined = f"{plain_prefix} - {structured_prefix} - {detail}{compact_marker}" + if len(structured_combined) <= structured_budget: + return structured_combined + + reduced_detail = detail + reduced_budget = max( + 10, + structured_budget - len(structured_prefix) - len(compact_marker) - 3, + ) + if len(reduced_detail) > reduced_budget: + reduced_detail = _compact_followup_detail_preserving_markers( + reduced_detail, reduced_budget + ) + + nested_marker_kind = None + if structured_prefix.startswith("code:"): + nested_marker_kind = "code" + elif structured_prefix.startswith("table:"): + nested_marker_kind = "table" + elif structured_prefix.startswith("quote:"): + nested_marker_kind = "quote" + elif structured_prefix.startswith("list:"): + nested_marker_kind = "list" + + if nested_marker_kind and ": " in plain_prefix: + primary_prefix = plain_prefix + primary_budget = max( + 14, + structured_budget + - len(f" [{nested_marker_kind}]") + - len(reduced_detail) + - len(compact_marker) + - 3, + ) + if len(primary_prefix) > primary_budget: + primary_prefix = _ellipsize_structured_followup( + primary_prefix, primary_budget + ) + reduced = ( + f"{primary_prefix} [{nested_marker_kind}]" + f" - {reduced_detail}{compact_marker}" + ) + if len(reduced) <= structured_budget: + return reduced + + reduced = f"{structured_prefix} - {reduced_detail}{compact_marker}" + if len(reduced) <= structured_budget: + return reduced + + prefix_budget = max(6, compact_budget - len(compact_marker) - len(detail) - 3) + if len(prefix) > prefix_budget: + if ": " in prefix: + prefix = _ellipsize_structured_followup(prefix, prefix_budget) + else: + prefix = _compact_plain_prefix_fragment(prefix, prefix_budget) + + combined = f"{prefix} - {detail}{compact_marker}" + if len(combined) <= compact_budget: + return combined + + detail_budget = max(20, compact_budget - len(compact_marker) - len(prefix) - 3) + if len(detail) > detail_budget: + detail = _ellipsize_structured_followup(detail, detail_budget) + return f"{prefix} - {detail}{compact_marker}" + + summary_budget = max(16, compact_budget - len(compact_marker)) + if len(summary) > summary_budget: + summary = _tail_weighted_ellipsize(summary, summary_budget) + return f"{summary}{compact_marker}" + + +def _assistant_preview_source(body: str) -> str: + lines = body.splitlines() + meaningful = [(i, line) for i, line in enumerate(lines) if line.strip()] + if not meaningful: + return body + + first_index, first_line = meaningful[0] + stripped_first = first_line.strip() + + if ( + _is_table_row(stripped_first) + and len(meaningful) > 1 + and _is_table_separator(meaningful[1][1]) + ): + table_end_index = first_index + 1 + for idx in range(first_index + 2, len(lines)): + if _is_table_row(lines[idx].strip()): + table_end_index = idx + continue + if lines[idx].strip(): + break + closing_text_summary = _find_followup_plain_text_summary(lines, table_end_index) + return _finalize_primary_assistant_preview( + _table_preview_summary(stripped_first), + lines, + table_end_index, + force_more=True, + closing_text_summary=closing_text_summary, + ) + + if stripped_first.startswith("```"): + lang = stripped_first[3:].strip() + code_line = "" + code_end_index = first_index + for later_index, later_line in meaningful[1:]: + later = later_line.strip() + if later.startswith("```"): + code_end_index = later_index + break + if not code_line: + code_line = _compact_code_preview_line(later) + label = f"{lang} code" if lang else "code" + closing_text_summary = _find_followup_plain_text_summary(lines, code_end_index) + if code_line: + return _finalize_primary_assistant_preview( + f"{label}: {code_line}", + lines, + code_end_index, + force_more=True, + closing_text_summary=closing_text_summary, + ) + return _finalize_primary_assistant_preview( + f"{label} block", + lines, + code_end_index, + force_more=True, + closing_text_summary=closing_text_summary, + ) + + if stripped_first.startswith(">"): + quote_preview = _first_quote_preview(meaningful) + if quote_preview: + quote_end_index = first_index + for idx in range(first_index + 1, len(lines)): + stripped = lines[idx].strip() + if not stripped: + continue + if stripped.startswith(">"): + quote_end_index = idx + continue + break + closing_text_summary = _find_followup_plain_text_summary(lines, quote_end_index) + return _finalize_primary_assistant_preview( + quote_preview.split("\n", 1)[0], + lines, + quote_end_index, + force_more="\n..." in quote_preview, + closing_text_summary=closing_text_summary, + ) + + if _is_list_line(stripped_first): + list_preview = _first_list_preview(meaningful) + if list_preview: + list_end_index = first_index + for idx in range(first_index + 1, len(lines)): + stripped = lines[idx].strip() + if not stripped: + continue + if _is_list_line(lines[idx]): + list_end_index = idx + continue + break + closing_text_summary = _find_followup_plain_text_summary(lines, list_end_index) + return _finalize_primary_assistant_preview( + list_preview.split("\n", 1)[0], + lines, + list_end_index, + force_more="\n..." in list_preview, + closing_text_summary=closing_text_summary, + ) + + summary = _strip_assistant_markdown_prefix(stripped_first) + has_more = len(meaningful) > 1 + followup_candidates = _find_followup_candidates_with_end_indices(lines, first_index) + + if followup_candidates: + kind, followup, first_followup_end_index = followup_candidates[0] + summary = _compose_assistant_followup_summary(summary, followup) + has_more = True + + plain_followup_candidates = _find_followup_plain_text_summaries_with_indices( + lines, first_followup_end_index + ) + closing_text_summary = None + if plain_followup_candidates: + if len(followup_candidates) > 1: + closing_text_summary = plain_followup_candidates[-1][0] + else: + closing_text_summary = plain_followup_candidates[0][0] + if closing_text_summary: + summary = _compose_assistant_followup_summary(summary, closing_text_summary) + + for marker_kind, _, _ in followup_candidates[1:]: + summary = _append_assistant_followup_marker(summary, marker_kind) + has_more = True + + return f"{summary}\n..." if has_more and summary else summary + + def preview_tool_body(tool_name: str, body: str) -> str: """Truncate tool output based on tool name and content size.""" max_chars = 1000 if tool_name == "read_file" else 1800 @@ -52,17 +1410,39 @@ def _render_transcript_entry(entry: TranscriptEntry) -> str: """Render a single TranscriptEntry with Morandi theme colors.""" t = theme() + if entry.kind == "assistant": + label = f"{t.assistant}{t.bold}{ICON_DOT} assistant{t.reset}" + if _should_compact_transcript_preview() and ( + "\n" in entry.body or len(entry.body) > _COMPACT_ASSISTANT_CHARS + ): + assistant_preview = _assistant_preview_source(entry.body) + body = render_markdownish( + assistant_preview + if assistant_preview != entry.body + else _compact_transcript_preview(assistant_preview, _COMPACT_ASSISTANT_CHARS) + ) + else: + body = render_markdownish(entry.body) + return f"{label}\n{_indent_block(body)}" + if entry.kind == "user": - label = f"{t.user}{t.bold}▶ you{t.reset}" + label = f"{t.user}{t.bold}鈻?you{t.reset}" return f"{label}\n{_indent_block(entry.body)}" if entry.kind == "assistant": - label = f"{t.assistant}{t.bold}▶ assistant{t.reset}" + label = f"{t.assistant}{t.bold}鈻?assistant{t.reset}" return f"{label}\n{_indent_block(render_markdownish(entry.body))}" if entry.kind == "progress": - label = f"{t.progress}{t.bold}▶ progress{t.reset}" - return f"{label}\n{_indent_block(render_markdownish(entry.body))}" + label = f"{t.progress}{t.bold}鈻?progress{t.reset}" + body = ( + render_markdownish( + _compact_transcript_preview(entry.body, _COMPACT_PROGRESS_CHARS) + ) + if _should_compact_transcript_preview() + else render_markdownish(entry.body) + ) + return f"{label}\n{_indent_block(body)}" if entry.kind == "tool": if entry.status == "running": @@ -89,20 +1469,24 @@ def _render_transcript_entry(entry: TranscriptEntry) -> str: toggle_text = f" {t.expandable}{t.bold}[collapsing]{t.reset}" else: toggle_text = ( - f" {t.expandable}{t.bold}[收起]{t.reset}" + f" {t.expandable}{t.bold}[鏀惰捣]{t.reset}" if not is_collapsed - else f" {t.expandable}{t.bold}[展开]{t.reset}" + else f" {t.expandable}{t.bold}[灞曞紑]{t.reset}" ) else: toggle_text = "" label = ( - f"{t.tool}{t.bold}▶ tool{t.reset} {tool_name_display}" + f"{t.tool}{t.bold}鈻?tool{t.reset} {tool_name_display}" f" {status_label}{toggle_text}" ) if entry.status == "running": - body = entry.body + body = ( + _compact_transcript_preview(entry.body, _COMPACT_TOOL_CHARS) + if _should_compact_transcript_preview() + else entry.body + ) elif is_collapsing: if collapsible_by_lines: preview = "\n".join(body_lines[:_TOOL_PREVIEW_LINES]) @@ -115,6 +1499,8 @@ def _render_transcript_entry(entry: TranscriptEntry) -> str: body = preview_tool_body(entry.toolName or "", render_markdownish(entry.body)) elif is_collapsed: summary = entry.collapsedSummary or "output collapsed" + if _should_compact_transcript_preview(): + summary = _compact_transcript_preview(summary, _COMPACT_TOOL_CHARS) body = f"{t.subtle}{t.italic}{summary}{t.reset}" else: if collapsible_by_lines: @@ -155,10 +1541,11 @@ class TranscriptLayout: int | None, str | None, str | None, + int, ] _entry_cache: dict[_EntryCacheKey, list[str]] = {} _line_count_cache: dict[_EntryCacheKey, int] = {} -_LayoutCacheKey = tuple[int, int, int] +_LayoutCacheKey = tuple[int, int, int, int] _layout_cache: dict[_LayoutCacheKey, TranscriptLayout] = {} _CACHE_MAX_SIZE = 500 _LAYOUT_CACHE_MAX_SIZE = 64 @@ -174,6 +1561,7 @@ def _entry_cache_key(entry: TranscriptEntry) -> _EntryCacheKey: entry.collapsePhase, entry.collapsedSummary, entry.toolName, + _transcript_render_columns(), ) @@ -221,7 +1609,7 @@ def _layout_cache_key( ) -> _LayoutCacheKey | None: if revision is None: return None - return (id(entries), revision, len(entries)) + return (id(entries), revision, len(entries), _transcript_render_columns()) def _build_transcript_layout( diff --git a/py-src/minicode/turn_kernel.py b/py-src/minicode/turn_kernel.py new file mode 100644 index 0000000..e8bb734 --- /dev/null +++ b/py-src/minicode/turn_kernel.py @@ -0,0 +1,646 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, Literal + +from minicode.layered_context import ContextBuilder, LayeredContext +from minicode.task_object import TaskState + + +@dataclass(slots=True) +class TurnPreludeState: + """Prelude artifacts prepared once before the recurrent tool loop.""" + + task: Any | None = None + task_metadata: dict[str, Any] = field(default_factory=dict) + layered_context: LayeredContext | None = None + context_builder: ContextBuilder | None = None + auditor: Any | None = None + + +@dataclass(slots=True) +class TurnRecurrentState: + """Mutable loop state for a single agent turn.""" + + max_steps: int | None + saw_tool_result: bool = False + empty_response_retry_count: int = 0 + recoverable_thinking_retry_count: int = 0 + tool_error_count: int = 0 + step: int = 0 + + def has_remaining_steps(self) -> bool: + return self.max_steps is None or self.step < self.max_steps + + def begin_step(self) -> int: + self.step += 1 + return self.step + + def can_retry_empty_response(self, limit: int = 2) -> bool: + return self.empty_response_retry_count < limit + + def record_empty_response_retry(self) -> None: + self.empty_response_retry_count += 1 + + def can_retry_recoverable_thinking(self, limit: int = 3) -> bool: + return self.recoverable_thinking_retry_count < limit + + def record_recoverable_thinking_retry(self) -> None: + self.recoverable_thinking_retry_count += 1 + + def record_tool_result(self, ok: bool) -> None: + self.saw_tool_result = True + if not ok: + self.tool_error_count += 1 + + def final_task_state(self) -> TaskState: + return TaskState.COMPLETED if self.tool_error_count == 0 else TaskState.FAILED + + +@dataclass(slots=True) +class AssistantTurnDecision: + """Structured outcome for one assistant response inside the recurrent loop.""" + + kind: Literal["progress", "retry", "fallback", "final"] + assistant_content: str | None = None + user_content: str | None = None + protect_final_answer: bool = False + + +@dataclass(slots=True) +class AssistantMessageReplay: + """Normalized callback and transcript payload for assistant-facing output.""" + + callback_kind: Literal["assistant", "progress"] | None + callback_content: str | None + transcript_messages: list[dict[str, Any]] + should_return: bool = False + protect_final_answer: bool = False + + +@dataclass(slots=True) +class ToolStepFeedback: + """Normalized measurements collected after one tool-execution step.""" + + error_rate: float + context_usage: float + avg_latency: float + oscillation_index: float + + +@dataclass(slots=True) +class ToolResultDecision: + """Transcript updates and control flow after a tool result is prepared.""" + + tool_result_content: str + transcript_messages: list[dict[str, Any]] + assistant_content: str | None = None + should_return: bool = False + + +@dataclass(slots=True) +class ToolResultReplay: + """Normalized replay payload for one completed tool result.""" + + callback_output: str + should_emit_callback: bool + should_increment_tool_calls: bool + conflicting_tool_names: list[str] + tool_decision: ToolResultDecision + + +@dataclass(slots=True) +class DeferredToolReplay: + """Whether a concurrent tool result needs deferred callback replay.""" + + should_replay: bool + + +@dataclass(slots=True) +class ToolBatchPlan: + """Normalized concurrent/serial execution plan for one tool batch.""" + + concurrent_calls: list[dict[str, Any]] + serial_calls: list[dict[str, Any]] + max_workers: int + + +@dataclass(slots=True) +class ToolReplayPlan: + """Ordered tool replay plan tuned for terminal/UI event sequencing.""" + + ordered_results: list[tuple[dict[str, Any], Any]] + deferred_start_calls: list[dict[str, Any]] + + +@dataclass(slots=True) +class TurnCodaSummary: + """Final per-turn summary used by coda bookkeeping and logging.""" + + step: int + tool_error_count: int + success: bool + result_summary: str + error_rate: float + avg_latency: float + context_usage: float + task_state: TaskState + + +def build_assistant_turn_replay( + *, + decision: AssistantTurnDecision, +) -> AssistantMessageReplay: + """Convert an assistant-turn decision into callback/transcript updates.""" + + if decision.kind == "progress": + transcript_messages: list[dict[str, Any]] = [] + if decision.assistant_content: + transcript_messages.append( + {"role": "assistant_progress", "content": decision.assistant_content} + ) + if decision.user_content: + transcript_messages.append({"role": "user", "content": decision.user_content}) + return AssistantMessageReplay( + callback_kind="progress" if decision.assistant_content else None, + callback_content=decision.assistant_content, + transcript_messages=transcript_messages, + ) + + if decision.kind == "retry": + transcript_messages = [] + if decision.user_content: + transcript_messages.append({"role": "user", "content": decision.user_content}) + return AssistantMessageReplay( + callback_kind=None, + callback_content=None, + transcript_messages=transcript_messages, + ) + + transcript_messages = [] + if decision.assistant_content: + transcript_messages.append( + {"role": "assistant", "content": decision.assistant_content} + ) + return AssistantMessageReplay( + callback_kind="assistant" if decision.assistant_content else None, + callback_content=decision.assistant_content, + transcript_messages=transcript_messages, + should_return=True, + protect_final_answer=decision.protect_final_answer, + ) + + +def build_step_content_replay( + *, + content: str, + content_kind: str | None, + has_calls: bool, + nudge_continue: str, +) -> AssistantMessageReplay: + """Normalize inline assistant content from a tool-call step for terminal replay.""" + + if content_kind == "progress": + return AssistantMessageReplay( + callback_kind="progress", + callback_content=content, + transcript_messages=[ + {"role": "assistant_progress", "content": content}, + {"role": "user", "content": nudge_continue}, + ], + ) + + return AssistantMessageReplay( + callback_kind="assistant", + callback_content=content, + transcript_messages=[{"role": "assistant", "content": content}], + should_return=not has_calls, + ) + + +def build_tool_step_feedback( + *, + turn_state: TurnRecurrentState, + context_usage: float, + oscillation_index: float, +) -> ToolStepFeedback: + """Build normalized measurements for step-end control feedback.""" + + return ToolStepFeedback( + error_rate=turn_state.tool_error_count / max(turn_state.step, 1), + context_usage=context_usage, + avg_latency=turn_state.step * 2.0, + oscillation_index=oscillation_index, + ) + + +def apply_tool_step_feedback( + *, + feedback: ToolStepFeedback, + decoupling_controller: Any | None, + self_healing_engine: Any | None, + log_healing: Callable[[str], None] | None = None, +) -> None: + """Apply step-end controller feedback after tool execution.""" + + if decoupling_controller: + decoupling_controller.record_measurement( + { + "token_usage_to_latency": ( + feedback.context_usage, + feedback.avg_latency / 60.0, + ), + "context_pressure_to_errors": ( + feedback.context_usage, + feedback.error_rate, + ), + } + ) + decoupling_controller.compute_decoupling_matrix() + + if self_healing_engine: + healing_actions = self_healing_engine.detect_and_heal( + { + "error_rate": feedback.error_rate, + "context_usage": feedback.context_usage, + "oscillation_index": feedback.oscillation_index, + } + ) + if healing_actions and log_healing: + log_healing(healing_actions[0].strategy) + + +def build_tool_result_context_output( + *, + turn_state: TurnRecurrentState, + tool_name: str, + result_output: str, + ok: bool, + classify_error: Callable[[str, str], Any], + generate_nudge: Callable[[Any, int], str], +) -> str: + """Build the tool_result content that should be fed back into the loop.""" + + if ok: + return result_output + + classified = classify_error(result_output, tool_name) + nudge = generate_nudge(classified, turn_state.tool_error_count) + return result_output + "\n\n[System note: " + nudge + "]" + + +def build_deferred_tool_replay( + *, + is_concurrency_safe: bool, + total_call_count: int, +) -> DeferredToolReplay: + """Determine whether callbacks/hooks must be replayed after concurrent execution.""" + + return DeferredToolReplay( + should_replay=is_concurrency_safe and total_call_count > 1, + ) + + +def build_tool_batch_plan( + *, + calls: list[dict[str, Any]], + concurrent_calls: list[dict[str, Any]], + serial_calls: list[dict[str, Any]], + get_recommended_max_workers: Callable[[list[dict[str, Any]]], int], +) -> ToolBatchPlan: + """Normalize a tool batch back into the model's original call order.""" + + call_order = {call["id"]: index for index, call in enumerate(calls)} + ordered_concurrent_calls = sorted( + concurrent_calls, + key=lambda call: call_order.get(call["id"], len(calls)), + ) + ordered_serial_calls = sorted( + serial_calls, + key=lambda call: call_order.get(call["id"], len(calls)), + ) + + max_workers = ( + get_recommended_max_workers(ordered_concurrent_calls) + if ordered_concurrent_calls + else 1 + ) + + return ToolBatchPlan( + concurrent_calls=ordered_concurrent_calls, + serial_calls=ordered_serial_calls, + max_workers=max_workers, + ) + + +def build_tool_replay_plan( + *, + calls: list[dict[str, Any]], + all_results: list[tuple[dict[str, Any], Any]], + is_concurrency_safe: Callable[[str], bool], +) -> ToolReplayPlan: + """Build a stable replay plan for tool callbacks and transcript ordering.""" + + call_order = {call["id"]: index for index, call in enumerate(calls)} + ordered_results = sorted( + all_results, + key=lambda pair: call_order.get(pair[0]["id"], len(calls)), + ) + + deferred_start_calls: list[dict[str, Any]] = [] + for call, _ in ordered_results: + replay = build_deferred_tool_replay( + is_concurrency_safe=is_concurrency_safe(call["toolName"]), + total_call_count=len(calls), + ) + if replay.should_replay: + deferred_start_calls.append(call) + + return ToolReplayPlan( + ordered_results=ordered_results, + deferred_start_calls=deferred_start_calls, + ) + + +def collect_conflicting_failed_tool_names( + *, + call_id: str, + ok: bool, + all_results: list[tuple[dict[str, Any], Any]], +) -> list[str]: + """Collect other failed tool names that should be marked as conflicts.""" + + if ok or len(all_results) <= 1: + return [] + + conflicting_names: list[str] = [] + for other_call, other_result in all_results: + if other_call["id"] == call_id: + continue + if not other_result.ok: + conflicting_names.append(other_call["toolName"]) + return conflicting_names + + +def apply_read_dedup_to_tool_result( + *, + dedup_manager: Any | None, + tool_name: str, + tool_input: dict[str, Any], + result_output: str, + ok: bool, + message_index: int, + log_dedup: Callable[[str], None] | None = None, +) -> str: + """Apply read-file deduplication and register the resulting transcript payload.""" + + if dedup_manager is None or not ok or tool_name != "read_file": + return result_output + + file_path = tool_input.get("path", "") + if not file_path: + return result_output + + if dedup_manager.should_dedup(file_path, result_output): + result_output = dedup_manager.get_stub(file_path) + if log_dedup: + log_dedup(file_path) + + dedup_manager.register_read(file_path, result_output, message_index) + return result_output + + +def build_tool_result_decision( + *, + call: dict[str, Any], + tool_name: str, + tool_input: dict[str, Any], + result_output: str, + is_error: bool, + await_user: bool, +) -> ToolResultDecision: + """Build transcript entries and await-user control flow for a tool result.""" + + transcript_messages = [ + { + "role": "assistant_tool_call", + "toolUseId": call["id"], + "toolName": tool_name, + "input": tool_input, + }, + { + "role": "tool_result", + "toolUseId": call["id"], + "toolName": tool_name, + "content": result_output, + "isError": is_error, + }, + ] + return ToolResultDecision( + tool_result_content=result_output, + transcript_messages=transcript_messages, + assistant_content=result_output if await_user else None, + should_return=await_user, + ) + + +def build_tool_result_replay( + *, + call: dict[str, Any], + result: Any, + turn_state: TurnRecurrentState, + total_call_count: int, + is_concurrency_safe: bool, + all_results: list[tuple[dict[str, Any], Any]], + dedup_manager: Any | None, + message_index: int, + classify_error: Callable[[str, str], Any], + generate_nudge: Callable[[Any, int], str], + log_dedup: Callable[[str], None] | None = None, +) -> ToolResultReplay: + """Normalize callback, transcript, and early-return decisions for one tool result.""" + + deferred_replay = build_deferred_tool_replay( + is_concurrency_safe=is_concurrency_safe, + total_call_count=total_call_count, + ) + + turn_state.record_tool_result(result.ok) + result_output = build_tool_result_context_output( + turn_state=turn_state, + tool_name=call["toolName"], + result_output=result.output, + ok=result.ok, + classify_error=classify_error, + generate_nudge=generate_nudge, + ) + result_output = apply_read_dedup_to_tool_result( + dedup_manager=dedup_manager, + tool_name=call["toolName"], + tool_input=call["input"], + result_output=result_output, + ok=result.ok, + message_index=message_index, + log_dedup=log_dedup, + ) + + return ToolResultReplay( + callback_output=result.output, + should_emit_callback=deferred_replay.should_replay, + should_increment_tool_calls=deferred_replay.should_replay, + conflicting_tool_names=collect_conflicting_failed_tool_names( + call_id=call["id"], + ok=result.ok, + all_results=all_results, + ), + tool_decision=build_tool_result_decision( + call=call, + tool_name=call["toolName"], + tool_input=call["input"], + result_output=result_output, + is_error=not result.ok, + await_user=result.awaitUser, + ), + ) + + +def build_turn_coda_summary( + *, + turn_state: TurnRecurrentState, + context_usage: float, +) -> TurnCodaSummary: + """Build a normalized turn summary for coda/finalization logic.""" + + task_state = turn_state.final_task_state() + success = task_state is TaskState.COMPLETED + return TurnCodaSummary( + step=turn_state.step, + tool_error_count=turn_state.tool_error_count, + success=success, + result_summary=( + f"Turn completed: {turn_state.step} steps, {turn_state.tool_error_count} errors" + ), + error_rate=turn_state.tool_error_count / max(turn_state.step, 1), + avg_latency=turn_state.step * 2.0, + context_usage=context_usage, + task_state=task_state, + ) + + +def finalize_work_chain_task( + *, + task: Any | None, + auditor: Any | None, + coda_summary: TurnCodaSummary, + success_outcome: Any, + failure_outcome: Any, +) -> None: + """Apply final task state and audit completion during coda.""" + + if task is None: + return + + task.set_state(coda_summary.task_state) + task.result_summary = coda_summary.result_summary + + if auditor is None: + return + + auditor.complete_decision( + success_outcome if coda_summary.success else failure_outcome, + coda_summary.step * 100.0, + task.result_summary, + task.error_message if not coda_summary.success else "", + ) + + +def decide_assistant_turn( + *, + turn_state: TurnRecurrentState, + step_content: str, + step_kind: str | None, + stop_reason: str | None, + block_types: list[str] | None, + ignored_block_types: list[str] | None, + is_empty: bool, + treat_as_progress: bool, + is_recoverable_thinking_stop: bool, + format_diagnostics: Callable[[str | None, list[str] | None, list[str] | None], str], + nudge_continue: str, + nudge_after_tool_result: str, + resume_after_pause: str, + resume_after_max_tokens: str, + nudge_after_empty_response: str, + nudge_after_empty_no_tools: str, +) -> AssistantTurnDecision: + """Decide how the loop should react to an assistant-only step.""" + + if treat_as_progress: + return AssistantTurnDecision( + kind="progress", + assistant_content=step_content, + user_content=( + nudge_after_tool_result + if turn_state.saw_tool_result and step_kind != "progress" + else nudge_continue + ), + ) + + if is_recoverable_thinking_stop and turn_state.can_retry_recoverable_thinking(): + turn_state.record_recoverable_thinking_retry() + progress_content = ( + "Model hit max_tokens during thinking; requesting the next step." + if stop_reason == "max_tokens" + else "Model returned pause_turn; requesting the next step." + ) + return AssistantTurnDecision( + kind="progress", + assistant_content=progress_content, + user_content=( + resume_after_pause + if stop_reason == "pause_turn" + else resume_after_max_tokens + ), + ) + + if is_empty and turn_state.can_retry_empty_response(): + turn_state.record_empty_response_retry() + return AssistantTurnDecision( + kind="retry", + user_content=( + nudge_after_empty_response + if turn_state.saw_tool_result + else nudge_after_empty_no_tools + ), + ) + + if is_empty: + diagnostics_suffix = format_diagnostics( + stop_reason, + block_types, + ignored_block_types, + ) + if turn_state.saw_tool_result: + fallback = ( + "Model returned an empty response after tool execution and the turn " + "was stopped. There were " + f"{turn_state.tool_error_count} tool error(s); retry, adjust the " + f"command, or choose a different approach.{diagnostics_suffix}" + if turn_state.tool_error_count > 0 + else "Model returned an empty response after tool execution and the " + "turn was stopped. Retry or ask the model to continue the remaining " + f"steps.{diagnostics_suffix}" + ) + else: + fallback = ( + "Model returned an empty response and the turn was stopped." + f"{diagnostics_suffix}" + ) + return AssistantTurnDecision(kind="fallback", assistant_content=fallback) + + return AssistantTurnDecision( + kind="final", + assistant_content=step_content, + protect_final_answer=True, + ) diff --git a/py-src/minicode/working_memory.py b/py-src/minicode/working_memory.py index a9f1091..40daa30 100644 --- a/py-src/minicode/working_memory.py +++ b/py-src/minicode/working_memory.py @@ -17,9 +17,6 @@ from dataclasses import dataclass, field from typing import Any -from minicode.context_manager import estimate_tokens - - @dataclass class WorkingMemoryEntry: """A single working memory entry that should be protected during compaction.""" @@ -38,6 +35,8 @@ def is_expired(self) -> bool: def token_count(self) -> int: """Estimate token count for this entry.""" + from minicode.context_manager import estimate_tokens + return estimate_tokens(self.content) diff --git a/py-src/tests/test_agent_loop.py b/py-src/tests/test_agent_loop.py index b106672..52be658 100644 --- a/py-src/tests/test_agent_loop.py +++ b/py-src/tests/test_agent_loop.py @@ -1,6 +1,7 @@ +import minicode.agent_loop as agent_loop_module from minicode.agent_loop import run_agent_turn from minicode.state import create_app_store -from minicode.tooling import ToolDefinition, ToolRegistry, ToolResult +from minicode.tooling import ToolCapability, ToolDefinition, ToolMetadata, ToolRegistry, ToolResult from minicode.types import AgentStep, ChatMessage, ModelAdapter, StepDiagnostics @@ -101,6 +102,59 @@ def run_echo(input_data: dict, _context) -> ToolResult: assert ("assistant", "done") in events +def test_agent_turn_keeps_progress_and_nudge_ahead_of_tool_results() -> None: + def run_echo(input_data: dict, _context) -> ToolResult: + return ToolResult(ok=True, output=f"echo:{input_data['text']}") + + registry = ToolRegistry( + [ + ToolDefinition( + name="echo", + description="echo tool", + input_schema={"type": "object"}, + validator=lambda value: value, + run=run_echo, + ) + ] + ) + model = ScriptedModel( + [ + AgentStep( + type="tool_calls", + content="working", + contentKind="progress", + calls=[{"id": "1", "toolName": "echo", "input": {"text": "hi"}}], + ), + AgentStep(type="assistant", content="done"), + ] + ) + + messages = run_agent_turn( + model=model, + tools=registry, + messages=[{"role": "system", "content": "sys"}], + cwd=".", + ) + + progress_index = next( + index + for index, message in enumerate(messages) + if message == {"role": "assistant_progress", "content": "working"} + ) + nudge_index = next( + index + for index, message in enumerate(messages) + if message["role"] == "user" and "Continue immediately" in message["content"] + ) + tool_result_index = next( + index + for index, message in enumerate(messages) + if message["role"] == "tool_result" + ) + + assert progress_index < nudge_index < tool_result_index + + def test_agent_turn_retries_empty_response_then_continues() -> None: model = ScriptedModel( [ @@ -170,6 +224,25 @@ def test_agent_turn_returns_fallback_after_repeated_empty_responses() -> None: assert "empty response" in messages[-1]["content"].lower() +def test_agent_turn_with_zero_max_steps_returns_limit_fallback() -> None: + registry = ToolRegistry([]) + model = ScriptedModel([AgentStep(type="assistant", content="unused")]) + + messages = run_agent_turn( + model=model, + tools=registry, + messages=[{"role": "system", "content": "sys"}], + cwd=".", + max_steps=0, + ) + + assert messages[-1] == { + "role": "assistant", + "content": "Reached the maximum tool step limit for this turn.", + } + assert model.calls == 0 + + def test_tool_registry_dispose_calls_disposer() -> None: disposed: list[bool] = [] registry = ToolRegistry([], disposer=lambda: disposed.append(True)) @@ -194,3 +267,268 @@ def test_agent_turn_passes_store_to_provider_adapter() -> None: assert messages[-1] == {"role": "assistant", "content": "done"} assert model.received_store is store + + +def test_agent_turn_returns_after_await_user_tool() -> None: + def run_ask_user(input_data: dict, _context) -> ToolResult: + return ToolResult(ok=True, output=input_data["question"], awaitUser=True) + + registry = ToolRegistry( + [ + ToolDefinition( + name="ask_user", + description="ask user tool", + input_schema={"type": "object"}, + validator=lambda value: value, + run=run_ask_user, + ) + ] + ) + model = ScriptedModel( + [ + AgentStep( + type="tool_calls", + calls=[ + { + "id": "1", + "toolName": "ask_user", + "input": {"question": "Need approval"}, + } + ], + ), + AgentStep(type="assistant", content="unused"), + ] + ) + + messages = run_agent_turn( + model=model, + tools=registry, + messages=[{"role": "system", "content": "sys"}], + cwd=".", + ) + + assert messages[-1] == {"role": "assistant", "content": "Need approval"} + assert model.calls == 1 + + +def test_agent_turn_replays_all_concurrent_results_before_await_user_return() -> None: + def run_tool(input_data: dict, _context) -> ToolResult: + if input_data["kind"] == "ask": + return ToolResult(ok=True, output="Need approval", awaitUser=True) + return ToolResult(ok=True, output="a.py\nb.py") + + registry = ToolRegistry( + [ + ToolDefinition( + name="ask_user", + description="ask user tool", + input_schema={"type": "object"}, + validator=lambda value: value, + run=run_tool, + metadata=ToolMetadata( + name="ask_user", + description="ask user tool", + capabilities={ToolCapability.CONCURRENCY_SAFE}, + ), + ), + ToolDefinition( + name="list_files", + description="list files", + input_schema={"type": "object"}, + validator=lambda value: value, + run=run_tool, + metadata=ToolMetadata( + name="list_files", + description="list files", + capabilities={ToolCapability.CONCURRENCY_SAFE}, + ), + ), + ] + ) + model = ScriptedModel( + [ + AgentStep( + type="tool_calls", + calls=[ + {"id": "1", "toolName": "ask_user", "input": {"kind": "ask"}}, + {"id": "2", "toolName": "list_files", "input": {"kind": "list"}}, + ], + ), + AgentStep(type="assistant", content="unused"), + ] + ) + + messages = run_agent_turn( + model=model, + tools=registry, + messages=[{"role": "system", "content": "sys"}], + cwd=".", + ) + + tool_results = [message for message in messages if message["role"] == "tool_result"] + assert len(tool_results) == 2 + assert tool_results[0]["toolName"] == "ask_user" + assert tool_results[1]["toolName"] == "list_files" + assert messages[-1] == {"role": "assistant", "content": "Need approval"} + assert model.calls == 1 + + +def test_agent_turn_replays_concurrent_tool_starts_before_results() -> None: + events: list[tuple[str, str]] = [] + + def run_read(input_data: dict, _context) -> ToolResult: + return ToolResult(ok=True, output=f"read:{input_data['path']}") + + registry = ToolRegistry( + [ + ToolDefinition( + name="read_file", + description="read file", + input_schema={"type": "object"}, + validator=lambda value: value, + run=run_read, + metadata=ToolMetadata( + name="read_file", + description="read file", + capabilities={ToolCapability.CONCURRENCY_SAFE}, + ), + ), + ToolDefinition( + name="list_files", + description="list files", + input_schema={"type": "object"}, + validator=lambda value: value, + run=run_read, + metadata=ToolMetadata( + name="list_files", + description="list files", + capabilities={ToolCapability.CONCURRENCY_SAFE}, + ), + ), + ] + ) + model = ScriptedModel( + [ + AgentStep( + type="tool_calls", + calls=[ + {"id": "1", "toolName": "read_file", "input": {"path": "a.py"}}, + {"id": "2", "toolName": "list_files", "input": {"path": "."}}, + ], + ), + AgentStep(type="assistant", content="done"), + ] + ) + + run_agent_turn( + model=model, + tools=registry, + messages=[{"role": "system", "content": "sys"}], + cwd=".", + on_tool_start=lambda name, _input: events.append(("start", name)), + on_tool_result=lambda name, _output, _error: events.append(("result", name)), + ) + + assert events[:4] == [ + ("start", "read_file"), + ("start", "list_files"), + ("result", "read_file"), + ("result", "list_files"), + ] + + +def test_agent_turn_reorders_batch_for_terminal_friendly_progress(monkeypatch) -> None: + events: list[tuple[str, str]] = [] + execution_order: list[str] = [] + + def run_tool(input_data: dict, _context) -> ToolResult: + execution_order.append(input_data["name"]) + return ToolResult(ok=True, output=f"ok:{input_data['name']}") + + registry = ToolRegistry( + [ + ToolDefinition( + name="read_file", + description="read file", + input_schema={"type": "object"}, + validator=lambda value: value, + run=run_tool, + metadata=ToolMetadata( + name="read_file", + description="read file", + capabilities={ToolCapability.CONCURRENCY_SAFE}, + ), + ), + ToolDefinition( + name="list_files", + description="list files", + input_schema={"type": "object"}, + validator=lambda value: value, + run=run_tool, + metadata=ToolMetadata( + name="list_files", + description="list files", + capabilities={ToolCapability.CONCURRENCY_SAFE}, + ), + ), + ToolDefinition( + name="write_file", + description="write file", + input_schema={"type": "object"}, + validator=lambda value: value, + run=run_tool, + ), + ToolDefinition( + name="edit_file", + description="edit file", + input_schema={"type": "object"}, + validator=lambda value: value, + run=run_tool, + ), + ] + ) + calls = [ + {"id": "1", "toolName": "read_file", "input": {"name": "read"}}, + {"id": "2", "toolName": "write_file", "input": {"name": "write"}}, + {"id": "3", "toolName": "list_files", "input": {"name": "list"}}, + {"id": "4", "toolName": "edit_file", "input": {"name": "edit"}}, + ] + model = ScriptedModel( + [ + AgentStep(type="tool_calls", calls=calls), + AgentStep(type="assistant", content="done"), + ] + ) + + def fake_schedule_calls(self, raw_calls, _tools): + assert raw_calls == calls + return [raw_calls[2], raw_calls[0]], [raw_calls[3], raw_calls[1]] + + monkeypatch.setattr( + agent_loop_module.ToolScheduler, + "schedule_calls", + fake_schedule_calls, + ) + monkeypatch.setattr( + agent_loop_module.ToolScheduler, + "get_recommended_max_workers", + lambda self, raw_calls: len(raw_calls), + ) + + run_agent_turn( + model=model, + tools=registry, + messages=[{"role": "system", "content": "sys"}], + cwd=".", + on_tool_start=lambda name, _input: events.append(("start", name)), + on_tool_result=lambda name, _output, _error: events.append(("result", name)), + ) + + assert events[:4] == [ + ("start", "read_file"), + ("start", "list_files"), + ("start", "write_file"), + ("result", "write_file"), + ] + assert ("start", "edit_file") in events + assert execution_order[-2:] == ["write", "edit"] diff --git a/py-src/tests/test_experiments.py b/py-src/tests/test_experiments.py new file mode 100644 index 0000000..72776ef --- /dev/null +++ b/py-src/tests/test_experiments.py @@ -0,0 +1,644 @@ +"""Tests for the experiment framework module.""" + +from __future__ import annotations + +import json +import sys +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, r"d:\Desktop\minicode\py-src") + +import pytest + +from minicode.experiments.configs import ( + ArchitectureConfig, + BenchmarkConfig, + ExperimentConfig, + SINGLE_AGENT_CONFIG, + SEQUENTIAL_CONFIG, + PARALLEL_CONFIG, + HIERARCHICAL_CONFIG, + CONSENSUS_CONFIG, + TOOL_MEDIATED_CONFIG, + ADAPTIVE_CONFIG, + ALL_ARCHITECTURE_CONFIGS, + SWE_BENCH_CONFIG, + NL2REPO_CONFIG, + get_pilot_configs, + generate_experiment_configs, +) +from minicode.experiments.experiment_logger import ( + ExperimentLogger, + ExperimentRun, + RunStatus, + TaskResult, +) +from minicode.experiments.metrics import ( + ExperimentMetrics, + compute_metrics, + aggregate_runs, + compute_improvement_over_baseline, +) +from minicode.experiments.analyzers.swe_bench_eval import ( + SWEBenchEvaluator, + SWEBenchTask, + SWEBenchResult, +) +from minicode.experiments.analyzers.nl2repo_eval import ( + NL2RepoEvaluator, + NL2RepoTask, + NL2RepoResult, +) + + +class TestConfigs: + """Test configuration definitions.""" + + def test_all_architectures_have_unique_names(self): + names = [c.name for c in ALL_ARCHITECTURE_CONFIGS] + assert len(names) == len(set(names)) + + def test_single_agent_is_not_multi(self): + assert not SINGLE_AGENT_CONFIG.is_multi_agent + assert SINGLE_AGENT_CONFIG.agent_count == 1 + + def test_all_multi_agent_have_count_gt_1(self): + for config in ALL_ARCHITECTURE_CONFIGS: + if config.is_multi_agent: + assert config.agent_count > 1 + + def test_adaptive_is_multi_agent(self): + assert ADAPTIVE_CONFIG.is_multi_agent + assert ADAPTIVE_CONFIG.adaptive + assert ADAPTIVE_CONFIG.max_adjustments == 3 + + def test_config_to_dict(self): + d = SINGLE_AGENT_CONFIG.to_dict() + assert d["name"] == "single" + assert not d["is_multi_agent"] + + def test_generate_experiment_configs_limited(self): + configs = generate_experiment_configs( + architectures=[SINGLE_AGENT_CONFIG], + benchmarks=[SWE_BENCH_CONFIG], + ) + assert len(configs) == 1 + assert configs[0].experiment_id == "swe_bench_single" + + def test_pilot_configs(self): + configs = get_pilot_configs() + assert len(configs) == 3 + names = {c.architecture.name for c in configs} + assert names == {"single", "sequential", "hierarchical"} + for c in configs: + assert c.benchmark.max_tasks == 5 + assert c.benchmark.num_runs == 1 + + def test_benchmark_config_timeout(self): + assert SWE_BENCH_CONFIG.timeout_per_task_seconds == 1800 + assert NL2REPO_CONFIG.timeout_per_task_seconds == 3600 + + def test_experiment_config_to_dict(self): + config = ExperimentConfig( + experiment_id="test_exp", + architecture=SINGLE_AGENT_CONFIG, + benchmark=SWE_BENCH_CONFIG, + ) + d = config.to_dict() + assert d["experiment_id"] == "test_exp" + assert d["model_name"] == "claude-sonnet-4-20250514" + assert d["seed"] == 42 + + +class TestExperimentLogger: + """Test the experiment logger.""" + + def test_create_and_complete_run(self, tmp_path): + logger = ExperimentLogger(str(tmp_path)) + run = logger.create_run( + experiment_id="test_exp", + architecture_name="single", + benchmark_name="swe_bench", + model_name="test-model", + ) + assert run.status == RunStatus.RUNNING + assert run.experiment_id == "test_exp" + + logger.complete_run(run.run_id, RunStatus.SUCCESS) + assert run.status == RunStatus.SUCCESS + assert run.end_time + + def test_add_task_result(self, tmp_path): + logger = ExperimentLogger(str(tmp_path)) + run = logger.create_run("exp1", "single", "swe", "m1") + + task = TaskResult( + task_id="task_1", + status=RunStatus.SUCCESS, + success=True, + duration_seconds=10.0, + ) + logger.add_task_result(run.run_id, task) + + assert run.task_count == 1 + assert run.pass_rate == 1.0 + + def test_persist_and_reload(self, tmp_path): + logger = ExperimentLogger(str(tmp_path)) + run = logger.create_run("exp1", "single", "swe", "m1") + run.task_results.append(TaskResult(task_id="t1", success=True)) + logger.complete_run(run.run_id, RunStatus.SUCCESS) + + loaded = logger.load_run(run.run_id) + assert loaded is not None + assert loaded.experiment_id == "exp1" + assert loaded.task_count == 1 + + def test_get_runs_by_architecture(self, tmp_path): + logger = ExperimentLogger(str(tmp_path)) + logger.create_run("e1", "single", "swe", "m1") + logger.create_run("e2", "parallel", "swe", "m1") + + single_runs = logger.get_runs_by_architecture("single") + assert len(single_runs) == 1 + + parallel_runs = logger.get_runs_by_architecture("parallel") + assert len(parallel_runs) == 1 + + def test_get_summary(self, tmp_path): + logger = ExperimentLogger(str(tmp_path)) + r1 = logger.create_run("e1", "single", "swe", "m1") + r1.task_results = [TaskResult(task_id="t1", success=True)] + logger.complete_run(r1.run_id) + + summary = logger.get_summary() + assert "swe|single" in summary + assert summary["swe|single"]["pass_rates"] == [1.0] + + +class TestMetrics: + """Test metrics computation.""" + + def test_compute_metrics_empty(self): + run = ExperimentRun(experiment_id="test") + metrics = compute_metrics(run) + assert metrics.pass_rate == 0.0 + assert metrics.success_rate == 0.0 + + def test_compute_metrics_all_pass(self): + run = ExperimentRun(experiment_id="test") + run.task_results = [ + TaskResult(task_id="t1", success=True, duration_seconds=5.0, + metrics={"input_tokens": 100, "output_tokens": 50}), + TaskResult(task_id="t2", success=True, duration_seconds=10.0, + metrics={"input_tokens": 200, "output_tokens": 100}), + ] + metrics = compute_metrics(run) + assert metrics.pass_rate == 1.0 + assert metrics.total_token_usage == 450 + assert metrics.estimated_cost_usd > 0 + + def test_compute_metrics_partial_pass(self): + run = ExperimentRun(experiment_id="test") + run.task_results = [ + TaskResult(task_id="t1", success=True, duration_seconds=5.0, metrics={}), + TaskResult(task_id="t2", success=False, status=RunStatus.FAILED, metrics={}), + ] + metrics = compute_metrics(run) + assert metrics.pass_rate == 0.5 + assert metrics.error_rate == 0.5 + + def test_aggregate_runs(self): + r1 = ExperimentRun(experiment_id="e1", architecture_name="single", benchmark_name="swe") + r1.task_results = [TaskResult(task_id="t1", success=True, metrics={})] + + r2 = ExperimentRun(experiment_id="e1", architecture_name="single", benchmark_name="swe") + r2.task_results = [TaskResult(task_id="t1", success=False, status=RunStatus.FAILED, metrics={})] + + agg = aggregate_runs([r1, r2]) + assert agg.pass_rate == 0.5 + + def test_compute_improvement_over_baseline(self): + baseline = ExperimentMetrics(pass_rate=0.5, estimated_cost_usd=1.0, error_rate=0.2, + mean_duration_seconds=100.0) + treatment = ExperimentMetrics(pass_rate=0.75, estimated_cost_usd=2.0, error_rate=0.1, + mean_duration_seconds=80.0) + + improvement = compute_improvement_over_baseline(treatment, baseline) + assert improvement["pass_rate_improvement"] == 0.5 + assert improvement["speedup"] > 1.0 + assert improvement["error_rate_diff"] == 0.1 + + def test_metrics_to_csv_row(self): + metrics = ExperimentMetrics( + architecture_name="single", + benchmark_name="swe", + pass_rate=0.8, + ) + row = metrics.to_csv_row() + assert row["architecture_name"] == "single" + assert row["pass_rate"] == "0.8000" + + +class TestSWEBenchEvaluator: + """Test SWE-bench evaluation.""" + + def test_load_tasks(self, tmp_path): + jsonl = tmp_path / "tasks.jsonl" + jsonl.write_text(json.dumps({ + "instance_id": "test_1", + "repo": "test/repo", + "problem_statement": "Fix bug", + "base_commit": "abc123", + "test_patch": "diff --git ...", + }) + "\n") + + tasks = SWEBenchEvaluator.load_tasks(str(jsonl), max_tasks=10) + assert len(tasks) == 1 + assert tasks[0].instance_id == "test_1" + + def test_extract_patch_from_markdown(self): + output = """Here is the fix: + +```diff +diff --git a/file.py b/file.py +index 123..456 +--- a/file.py ++++ b/file.py +@@ -1 +1 @@ +-old ++new +``` + +Done.""" + patch = SWEBenchEvaluator._extract_patch(output) + assert "diff --git" in patch + assert "+new" in patch + + def test_extract_patch_no_markers(self): + output = "diff --git a/file.py b/file.py\n--- a/file.py\n+++ b/file.py\n-old\n+new" + patch = SWEBenchEvaluator._extract_patch(output) + assert "diff --git" in patch + + def test_compute_pass_rate(self): + results = [ + SWEBenchResult(instance_id="1", resolved=True), + SWEBenchResult(instance_id="2", resolved=False), + SWEBenchResult(instance_id="3", resolved=True), + ] + stats = SWEBenchEvaluator().compute_pass_rate(results) + assert stats["pass_rate"] == 2 / 3 + assert stats["resolved_count"] == 2 + assert stats["total_count"] == 3 + + def test_empty_patch_fails(self): + task = SWEBenchTask( + instance_id="test", + repo="test/repo", + problem_statement="test", + base_commit="abc", + test_patch="", + ) + result = SWEBenchEvaluator().evaluate_patch(task, "") + assert not result.resolved + assert "Empty" in result.error_message + + +class TestNL2RepoEvaluator: + """Test NL2Repo evaluation.""" + + def test_load_tasks(self, tmp_path): + jsonl = tmp_path / "tasks.jsonl" + jsonl.write_text(json.dumps({ + "task_id": "nl2r_1", + "requirement_doc": "Build a calculator library", + "test_dir": "tests", + "expected_files": ["calculator.py"], + }) + "\n") + + tasks = NL2RepoEvaluator.load_tasks(str(jsonl), max_tasks=10) + assert len(tasks) == 1 + assert tasks[0].task_id == "nl2r_1" + + def test_parse_pytest_output(self): + output = "collected 10 items\n\n...\n\n======= 8 passed, 2 failed in 0.5s =======" + total, passed, failed = NL2RepoEvaluator._parse_pytest_output(output) + assert total == 10 + assert passed == 8 + assert failed == 2 + + def test_parse_pytest_all_pass(self): + output = "10 passed in 0.3s" + total, passed, failed = NL2RepoEvaluator._parse_pytest_output(output) + assert total == 10 + assert passed == 10 + assert failed == 0 + + def test_extract_files(self, tmp_path): + output = """# File: main.py +```python +def hello(): + return "world" +``` + +# File: utils.py +```python +def add(a, b): + return a + b +```""" + NL2RepoEvaluator._extract_files(output, tmp_path) + main_file = tmp_path / "main.py" + utils_file = tmp_path / "utils.py" + assert main_file.exists() + assert utils_file.exists() + assert "def hello" in main_file.read_text() + + def test_compute_pass_rate(self): + results = [ + NL2RepoResult(task_id="1", pass_rate=0.8, total_tests=10, passed_tests=8, failed_tests=2), + NL2RepoResult(task_id="2", pass_rate=1.0, total_tests=5, passed_tests=5, failed_tests=0), + ] + stats = NL2RepoEvaluator().compute_pass_rate(results) + assert stats["mean_pass_rate"] == 0.9 + assert stats["total_tests"] == 15 + assert stats["total_passed"] == 13 + + +class TestIntegration: + """Integration tests for the experiment framework.""" + + def test_full_pipeline_without_model(self, tmp_path): + """Test the experiment pipeline without actual model calls.""" + from minicode.experiments.configs import get_pilot_configs + + configs = get_pilot_configs() + assert len(configs) == 3 + + logger = ExperimentLogger(str(tmp_path)) + for config in configs: + run = logger.create_run( + experiment_id=config.experiment_id, + architecture_name=config.architecture.name, + benchmark_name=config.benchmark.name, + model_name=config.model_name, + run_index=0, + ) + run.task_results.append(TaskResult(task_id="pilot_1", success=True, metrics={ + "input_tokens": 500, + "output_tokens": 200, + })) + logger.complete_run(run.run_id, RunStatus.SUCCESS) + + summary = logger.get_summary() + assert len(summary) == 3 + + def test_generate_experiment_matrix(self): + configs = generate_experiment_configs( + architectures=[SINGLE_AGENT_CONFIG, PARALLEL_CONFIG], + benchmarks=[SWE_BENCH_CONFIG, NL2REPO_CONFIG], + ) + assert len(configs) == 4 + + ids = {c.experiment_id for c in configs} + assert "swe_bench_single" in ids + assert "swe_bench_parallel" in ids + assert "nl2repo_single" in ids + assert "nl2repo_parallel" in ids + + +class TestMLEBenchEvaluator: + """Test MLE-bench evaluation.""" + + def test_load_tasks(self, tmp_path): + from minicode.experiments.analyzers.mle_bench_eval import MLEBenchEvaluator + + jsonl = tmp_path / "tasks.jsonl" + jsonl.write_text(json.dumps({ + "competition_id": "titanic", + "description": "Predict survival on Titanic", + "metric": "accuracy", + "time_limit_hours": 24, + }) + "\n") + + tasks = MLEBenchEvaluator.load_tasks(str(jsonl), max_tasks=10) + assert len(tasks) == 1 + assert tasks[0].competition_id == "titanic" + assert tasks[0].metric == "accuracy" + + def test_extract_score_accuracy(self): + from minicode.experiments.analyzers.mle_bench_eval import MLEBenchEvaluator + + output = "Training complete.\nAccuracy: 0.923\nDone." + score = MLEBenchEvaluator._extract_score(output) + assert score == 0.923 + + def test_extract_score_f1(self): + from minicode.experiments.analyzers.mle_bench_eval import MLEBenchEvaluator + + output = "F1-Score: 0.85" + score = MLEBenchEvaluator._extract_score(output) + assert score == 0.85 + + def test_extract_score_percentage(self): + from minicode.experiments.analyzers.mle_bench_eval import MLEBenchEvaluator + + output = "Score: 85.5" + score = MLEBenchEvaluator._extract_score(output) + assert score == 0.855 + + def test_determine_medal(self): + from minicode.experiments.analyzers.mle_bench_eval import MLEBenchEvaluator + + assert MLEBenchEvaluator._determine_medal(0.96) == "gold" + assert MLEBenchEvaluator._determine_medal(0.90) == "silver" + assert MLEBenchEvaluator._determine_medal(0.80) == "bronze" + assert MLEBenchEvaluator._determine_medal(0.60) == "" + + def test_compute_summary(self): + from minicode.experiments.analyzers.mle_bench_eval import ( + MLEBenchEvaluator, MLEBenchResult, + ) + + results = [ + MLEBenchResult(competition_id="c1", score=0.96, medal="gold"), + MLEBenchResult(competition_id="c2", score=0.82, medal="bronze"), + MLEBenchResult(competition_id="c3", score=0.60, medal=""), + ] + summary = MLEBenchEvaluator().compute_summary(results) + assert summary["mean_score"] > 0.7 + assert summary["medal_counts"]["gold"] == 1 + assert summary["medal_counts"]["bronze"] == 1 + assert summary["medal_counts"]["none"] == 1 + assert summary["any_medal_rate"] == 2 / 3 + + def test_extract_files_from_output(self, tmp_path): + from minicode.experiments.analyzers.mle_bench_eval import MLEBenchEvaluator + + output = """# File: train.py +```python +import pandas as pd +def train(): + pass +``` + +# File: predict.py +```python +def predict(): + pass +```""" + MLEBenchEvaluator._extract_files(output, tmp_path) + assert (tmp_path / "train.py").exists() + assert (tmp_path / "predict.py").exists() + + +class TestPaperBenchEvaluator: + """Test PaperBench evaluation.""" + + def test_load_tasks(self, tmp_path): + from minicode.experiments.analyzers.paper_bench_eval import PaperBenchEvaluator + + jsonl = tmp_path / "tasks.jsonl" + jsonl.write_text(json.dumps({ + "paper_id": "pp_001", + "paper_title": "Attention Is All You Need", + "paper_text": "The dominant sequence transduction models...", + "total_points": 100, + "time_limit_hours": 12, + }) + "\n") + + tasks = PaperBenchEvaluator.load_tasks(str(jsonl), max_tasks=5) + assert len(tasks) == 1 + assert tasks[0].paper_id == "pp_001" + + def test_eval_code_structure(self, tmp_path): + from minicode.experiments.analyzers.paper_bench_eval import PaperBenchEvaluator + + (tmp_path / "module").mkdir() + (tmp_path / "module" / "__init__.py").write_text("") + (tmp_path / "main.py").write_text("def main(): pass") + (tmp_path / "config.py").write_text("batch_size = 32") + (tmp_path / "requirements.txt").write_text("torch>=2.0") + (tmp_path / "README.md").write_text("# Paper Reproduction") + + score = PaperBenchEvaluator._eval_code_structure(tmp_path) + assert score >= 0.5 + + def test_eval_code_structure_empty(self, tmp_path): + from minicode.experiments.analyzers.paper_bench_eval import PaperBenchEvaluator + + score = PaperBenchEvaluator._eval_code_structure(tmp_path) + assert score == 0.0 + + def test_eval_method_implementation(self, tmp_path): + from minicode.experiments.analyzers.paper_bench_eval import PaperBenchEvaluator, PaperBenchTask + + (tmp_path / "model.py").write_text( + "import torch\nclass Transformer:\n def forward(self, x):\n return x\n" + ) + (tmp_path / "train.py").write_text( + "def train():\n # training loop\n pass\n" + ) + + task = PaperBenchTask(paper_id="test", paper_text="test") + score = PaperBenchEvaluator._eval_method_implementation(task, tmp_path) + assert score > 0.3 + + def test_eval_reproducibility(self, tmp_path): + from minicode.experiments.analyzers.paper_bench_eval import PaperBenchEvaluator + + (tmp_path / "main.py").write_text( + "import random\nrandom.seed(42)\nimport torch\ntorch.manual_seed(42)\n" + ) + + score = PaperBenchEvaluator._eval_reproducibility(tmp_path) + assert score >= 0.6 + + def test_eval_documentation(self, tmp_path): + from minicode.experiments.analyzers.paper_bench_eval import PaperBenchEvaluator + + (tmp_path / "README.md").write_text("# Paper Reproduction\n\nThis repository reproduces...\n\n## Setup\n\n```pip install -r requirements.txt```\n\n## Usage\n\n```python main.py```\n") + (tmp_path / "main.py").write_text( + '"""Main entry point."""\n\ndef main() -> None:\n # Run experiment\n pass\n' + ) + + score = PaperBenchEvaluator._eval_documentation(tmp_path) + assert score >= 0.5 + + def test_compute_summary(self, tmp_path): + from minicode.experiments.analyzers.paper_bench_eval import ( + PaperBenchEvaluator, PaperBenchResult, + ) + + results = [ + PaperBenchResult(paper_id="p1", score=85.0, max_score=100, + rubric_results={"code_structure": 0.8, "method_implementation": 0.9}, + files_generated=5), + PaperBenchResult(paper_id="p2", score=60.0, max_score=100, + rubric_results={"code_structure": 0.6, "method_implementation": 0.6}, + files_generated=3), + ] + summary = PaperBenchEvaluator().compute_summary(results) + assert summary["mean_normalized"] == 0.725 + assert summary["total_papers"] == 2 + assert summary["mean_files_generated"] == 4.0 + + def test_empty_summary(self): + from minicode.experiments.analyzers.paper_bench_eval import PaperBenchEvaluator + + summary = PaperBenchEvaluator().compute_summary([]) + assert summary["mean_score"] == 0.0 + assert summary["total_papers"] == 0 + + +class TestAnalyzeResults: + """Test the analyze_results script functions.""" + + def test_welch_ttest_equal(self): + import importlib.util + spec = importlib.util.spec_from_file_location( + "analyze_results", + r"d:\Desktop\minicode\py-src\scripts\analyze_results.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + t, p = mod._welch_ttest([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]) + assert abs(t) < 0.001 + + def test_welch_ttest_different(self): + import importlib.util + spec = importlib.util.spec_from_file_location( + "analyze_results", + r"d:\Desktop\minicode\py-src\scripts\analyze_results.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + t, p = mod._welch_ttest([0.5, 0.5, 0.5], [0.8, 0.8, 0.8]) + assert abs(t) > 1.0 + + def test_cohens_d(self): + import importlib.util + spec = importlib.util.spec_from_file_location( + "analyze_results", + r"d:\Desktop\minicode\py-src\scripts\analyze_results.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + d = mod._cohens_d([0.5, 0.5, 0.5], [0.8, 0.8, 0.8]) + assert d > 1.0 + + def test_cohens_d_small(self): + import importlib.util + spec = importlib.util.spec_from_file_location( + "analyze_results", + r"d:\Desktop\minicode\py-src\scripts\analyze_results.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + d = mod._cohens_d([0.5, 0.5, 0.5], [0.55, 0.55, 0.55]) + assert 0 < d < 1.0 \ No newline at end of file diff --git a/py-src/tests/test_tty_app.py b/py-src/tests/test_tty_app.py index 1a55e24..d810a52 100644 --- a/py-src/tests/test_tty_app.py +++ b/py-src/tests/test_tty_app.py @@ -1,3 +1,5 @@ +import re + from minicode.tty_app import ( _ThrottledRenderer, _apply_tool_result_visual_state, @@ -8,9 +10,12 @@ summarize_tool_output, ) import minicode.tui.input_handler as input_handler_module +import minicode.tui.chrome as chrome_module +import minicode.tui.transcript as transcript_module from minicode.context_manager import ContextManager from minicode.permissions import PermissionManager -from minicode.tooling import ToolRegistry +from minicode.tooling import ToolCapability, ToolDefinition, ToolMetadata, ToolRegistry, ToolResult +from minicode.tui.tool_helpers import _record_recent_tool from minicode.tui.runtime_control import _ThrottledRenderer as RuntimeThrottledRenderer from minicode.tui.event_flow import _handle_event from minicode.tui.input_parser import KeyEvent @@ -95,11 +100,1424 @@ def test_mark_unfinished_tools_marks_running_entries_as_errors() -> None: assert count == 1 assert state.transcript[0].status == "error" assert "did not report a final result" in state.transcript[0].body - assert state.recent_tools == [{"name": "run_command", "status": "error"}] + assert state.recent_tools[-1]["name"] == "run_command" + assert state.recent_tools[-1]["status"] == "error" assert state.pending_tool_runs == {} assert state.active_tool is None +def test_record_recent_tool_merges_duplicates_and_caps_history() -> None: + state = type("State", (), {"recent_tools": []})() + + _record_recent_tool(state, "read_file", "success", display_name="read_file (1.2s)", max_items=3) + _record_recent_tool(state, "read_file", "success", display_name="read_file (0.8s)", max_items=3) + _record_recent_tool(state, "run_command", "error", max_items=3) + _record_recent_tool(state, "list_files", "success", max_items=3) + _record_recent_tool(state, "write_file", "success", max_items=3) + + assert state.recent_tools == [ + {"tool": "run_command", "name": "run_command", "status": "error", "count": 1}, + {"tool": "list_files", "name": "list_files", "status": "success", "count": 1}, + {"tool": "write_file", "name": "write_file", "status": "success", "count": 1}, + ] + + +def test_record_recent_tool_uses_compact_label_for_consecutive_duplicates() -> None: + state = type("State", (), {"recent_tools": []})() + + _record_recent_tool(state, "read_file", "success", display_name="read_file (1.2s)") + _record_recent_tool(state, "read_file", "success", display_name="read_file (0.8s)") + + assert state.recent_tools == [ + {"tool": "read_file", "name": "read_file x2", "status": "success", "count": 2}, + ] + + +def test_render_tool_panel_truncates_long_labels_for_narrow_terminal(monkeypatch) -> None: + monkeypatch.setattr(chrome_module, "_cached_terminal_size", lambda: (52, 20)) + + rendered = chrome_module.render_tool_panel( + "run_command python scripts/very/deep/path/to/long_runner.py --flag value", + [ + {"name": r"read_file path=C:\Users\question\projects\minicode\src\very_long_file_name.py", "status": "success"}, + {"name": "apply_patch with a very long summary that should not stretch the panel forever", "status": "error"}, + ], + background_tasks=[{"status": "running", "label": "background synchronization task with a long label"}], + ) + plain = re.sub(r"\x1b\[[0-9;]*m", "", rendered) + + assert "..." in plain + assert "very_long_file_name.py" not in plain + assert "background synchronization task with a long label" not in plain + assert "apply_patch with a very long summary that should not stretch the panel forever" not in plain + + +def test_render_footer_bar_compacts_right_side_on_narrow_terminal(monkeypatch) -> None: + monkeypatch.setattr(chrome_module, "_cached_terminal_size", lambda: (44, 20)) + + rendered = chrome_module.render_footer_bar( + "Running extremely long tool status for verification", + tools_enabled=True, + skills_enabled=False, + background_tasks=[{"status": "running"}, {"status": "running"}], + ) + plain = re.sub(r"\x1b\[[0-9;]*m", "", rendered) + + assert chrome_module.string_display_width(rendered) <= 44 + assert "tools" not in plain + assert "skills" not in plain + assert "..." in plain + assert "2 bg" not in plain + + +def test_render_transcript_compacts_progress_preview_on_narrow_terminal(monkeypatch) -> None: + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + + rendered = transcript_module.render_transcript( + [ + TranscriptEntry( + id=1, + kind="progress", + body="Scanning project for matching files across several directories\nsecond detail line\nthird detail line", + ) + ], + scroll_offset=0, + window_size=8, + revision=1, + ) + plain = re.sub(r"\x1b\[[0-9;]*m", "", rendered) + + assert "Scanning project for matchi" in plain + assert "directories" in plain + assert "..." in plain + assert "second detail line" not in plain + + +def test_render_transcript_compacts_assistant_list_preview_on_narrow_terminal(monkeypatch) -> None: + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + + rendered = transcript_module.render_transcript( + [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "1. Inspect transcript renderer for narrow terminal layout\n" + "2. Run focused pytest\n" + "3. Summarize findings" + ), + ) + ], + scroll_offset=0, + window_size=8, + revision=11, + ) + plain = re.sub(r"\x1b\[[0-9;]*m", "", rendered) + + assert "Inspect transcript renderer" in plain + assert "..." in plain + assert "Run focused pytest" not in plain + + +def test_render_transcript_compacts_assistant_code_block_preview_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body="```python\ndef build_turn_state(state):\n return state\n```", + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=12) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=12) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "python code:" in narrow_plain + assert "build_turn_state" in narrow_plain + assert "return state" not in narrow_plain + assert "return state" in wide_plain + + +def test_render_transcript_compacts_assistant_table_preview_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "| Step | Owner | Status | Notes |\n" + "| --- | --- | --- | --- |\n" + "| transcript | codex | done | narrowed preview |\n" + "| tty | codex | next | verify quote layout |" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=13) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=13) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "table: Step | Owner | Status" in narrow_plain + assert "Notes" not in narrow_plain + assert "narrowed preview" not in narrow_plain + assert "transcript" in wide_plain + assert "codex" in wide_plain + assert "done" in wide_plain + assert "narrowed preview" in wide_plain + + +def test_render_transcript_compacts_assistant_quote_preview_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "> Keep the first screen focused on the next action.\n" + "> Trim repeated context before showing tool details.\n\n" + "Then continue with the wider explanation." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=14) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=14) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "quote: Keep the first screen focused on the next action." in narrow_plain + assert "Trim repeated context before showing tool details." not in narrow_plain + assert "Trim repeated context before showing tool details." in wide_plain + + +def test_render_transcript_compacts_nested_list_preview_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "- Tighten transcript preview ordering\n" + " - Keep progress ahead of tool results\n" + " - Collapse repeated tool chatter\n" + "- Re-run focused pytest" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=17) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=17) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "list: Tighten transcript preview ordering" in narrow_plain + assert "Keep progress ahead of tool results" not in narrow_plain + assert "Keep progress ahead of tool results" in wide_plain + + +def test_render_transcript_compacts_intro_plus_table_preview_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Deployment summary for this turn.\n\n" + "| Step | Owner | Status | Notes |\n" + "| --- | --- | --- | --- |\n" + "| transcript | codex | done | narrowed preview |\n" + "| tty | codex | next | verify quote layout |" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=15) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "table: Step | Owner | Status" in narrow_plain + assert "this turn" in narrow_plain + assert "narrowed preview" not in narrow_plain + + +def test_render_transcript_compacts_intro_plus_list_preview_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Next pass priorities.\n\n" + "- Tighten transcript preview ordering\n" + " - Keep progress ahead of tool results\n" + "- Re-run focused pytest" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=18) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert " - list:" in narrow_plain + assert "Tighten transcript preview orde" in narrow_plain + assert "Keep progress ahead of tool results" not in narrow_plain + + +def test_render_transcript_compacts_intro_plus_quote_preview_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Recommendation for the next pass.\n\n" + "> Keep the first screen focused on the next action.\n" + "> Trim repeated context before showing tool details." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=16) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert " - quote:" in narrow_plain + assert "next pass" in narrow_plain + assert "focused on the next action." in narrow_plain + assert "Trim repeated context before showing tool details." not in narrow_plain + + +def test_render_transcript_compacts_intro_plus_code_preview_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Patch walkthrough for the next turn.\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=19) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=19) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "python code:" in narrow_plain + assert "build_turn_state" in narrow_plain + assert "return state" not in narrow_plain + assert "return state" in wide_plain + + +def test_render_transcript_compacts_intro_plus_code_and_list_preview_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Patch walkthrough for the next turn.\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```\n\n" + "- Re-run focused pytest\n" + "- Save transcript snapshot" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=20) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=20) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "python code:" in narrow_plain + assert "build_turn_state" in narrow_plain + assert "[list]" in narrow_plain + assert "Re-run focused pytest" not in narrow_plain + assert "Re-run focused pytest" in wide_plain + + +def test_render_transcript_keeps_inline_more_indicator_for_code_preview_when_it_fits( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```\n\n" + "Keep verification focused on state anchors before broadening retrieval.\n\n" + "- Re-run focused pytest\n" + "- Save transcript snapshot" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=201) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + lines = narrow_plain.splitlines() + assert any("python code: build_turn_state" in line for line in lines) + assert lines[-1].endswith("...") + assert "anchors [list]" in narrow_plain + assert "\n..." not in narrow_plain + + +def test_render_transcript_compacts_intro_plus_code_and_table_preview_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Patch walkthrough for the next turn.\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```\n\n" + "| Step | Owner | Status | Notes |\n" + "| --- | --- | --- | --- |\n" + "| transcript | codex | done | narrowed preview |\n" + "| tty | codex | next | verify quote layout |" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=21) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=21) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "python code:" in narrow_plain + assert "build_turn_state" in narrow_plain + assert "[table]" in narrow_plain + assert "Step | Owner | Status" not in narrow_plain + assert "narrowed preview" not in narrow_plain + assert "narrowed preview" in wide_plain + + +def test_render_transcript_compacts_intro_plus_code_and_quote_preview_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Patch walkthrough for the next turn.\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```\n\n" + "> Keep the first screen focused on the next action.\n" + "> Trim repeated context before showing tool details." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=22) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=22) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "python code:" in narrow_plain + assert "build_turn_state" in narrow_plain + assert "[quote]" in narrow_plain + assert "focused on the next action." not in narrow_plain + assert "focused on the next action." in wide_plain + + +def test_render_transcript_compacts_intro_plus_code_then_closing_text_then_list_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Patch walkthrough for the next turn.\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```\n\n" + "Keep verification focused on state anchors before broadening retrieval.\n\n" + "- Re-run focused pytest\n" + "- Save transcript snapshot" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=30) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=30) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "python code:" in narrow_plain + assert "build_turn_state" in narrow_plain + assert "state anchors" in narrow_plain + assert "[list]" in narrow_plain + assert "Re-run focused pytest" not in narrow_plain + assert "Re-run focused pytest" in wide_plain + + +def test_render_transcript_compacts_intro_plus_code_then_closing_text_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Patch walkthrough for the next turn.\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```\n\n" + "Return to the terminal after the focused rerun." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=32) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=32) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "python code:" in narrow_plain + assert "build_turn_state" in narrow_plain + assert "terminal" in narrow_plain + assert "Return to the terminal after the focused rerun." in wide_plain + + +def test_render_transcript_compacts_intro_plus_table_then_closing_text_then_quote_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Deployment summary for this turn.\n\n" + "| Step | Owner | Status | Notes |\n" + "| --- | --- | --- | --- |\n" + "| transcript | codex | done | narrowed preview |\n" + "| tty | codex | next | verify quote layout |\n\n" + "Keep verification focused on state anchors before broadening retrieval.\n\n" + "> Keep the first screen focused on the next action.\n" + "> Trim repeated context before showing tool details." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=31) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=31) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "table: Step | Owner | Status" in narrow_plain + assert "state anchors" in narrow_plain + assert "[quote]" in narrow_plain + assert "focused on the next action." not in narrow_plain + assert "focused on the next action." in wide_plain + + +def test_render_transcript_compacts_intro_plus_table_then_closing_text_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Deployment summary for this turn.\n\n" + "| Step | Owner | Status | Notes |\n" + "| --- | --- | --- | --- |\n" + "| transcript | codex | done | narrowed preview |\n" + "| tty | codex | next | verify quote layout |\n\n" + "Return to the terminal after the focused rerun." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=33) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=33) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "table: Step | Owner | Status" in narrow_plain + assert "terminal" in narrow_plain + assert "Return to the terminal after the focused rerun." in wide_plain + + +def test_render_transcript_compacts_quote_first_then_closing_text_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "> Keep the first screen focused on the next action.\n" + "> Trim repeated context before showing tool details.\n\n" + "Return to the terminal after the focused rerun." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=34) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=34) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "[quote]" in narrow_plain or "quote:" in narrow_plain + assert "terminal" in narrow_plain + assert "Return to the terminal after the focused rerun." in wide_plain + + +def test_render_transcript_compacts_list_first_then_closing_text_on_narrow_terminal(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "- Tighten transcript preview ordering\n" + "- Re-run focused pytest\n\n" + "Return to the terminal after the focused rerun." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=35) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=35) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "list:" in narrow_plain + assert "terminal" in narrow_plain + assert "Return to the terminal after the focused rerun." in wide_plain + + +def test_render_transcript_preserves_followup_order_for_intro_plus_quote_then_code(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Patch walkthrough for the next turn.\n\n" + "> Keep the first screen focused on the next action.\n" + "> Trim repeated context before showing tool details.\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=23) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert " - quote:" in narrow_plain + assert "[code]" in narrow_plain + assert "python code:" not in narrow_plain + + +def test_render_transcript_preserves_followup_order_for_intro_plus_table_then_code(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Patch walkthrough for the next turn.\n\n" + "| Step | Owner | Status | Notes |\n" + "| --- | --- | --- | --- |\n" + "| transcript | codex | done | narrowed preview |\n" + "| tty | codex | next | verify quote layout |\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=24) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert " - table:" in narrow_plain + assert "[code]" in narrow_plain + assert "python code:" not in narrow_plain + + +def test_render_transcript_compacts_code_block_first_with_quote_marker(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```\n\n" + "> Keep the first screen focused on the next action.\n" + "> Trim repeated context before showing tool details." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=25) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "python code:" in narrow_plain + assert "build_turn_state" in narrow_plain + assert "[quote]" in narrow_plain + + +def test_render_transcript_compacts_table_first_with_code_marker(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "| Step | Owner | Status | Notes |\n" + "| --- | --- | --- | --- |\n" + "| transcript | codex | done | narrowed preview |\n" + "| tty | codex | next | verify quote layout |\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=26) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "table: Step | Owner | Status" in narrow_plain + assert "[code]" in narrow_plain + + +def test_render_transcript_compacts_list_first_with_quote_marker(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "- Tighten transcript preview ordering\n" + "- Re-run focused pytest\n\n" + "> Keep the first screen focused on the next action.\n" + "> Trim repeated context before showing tool details." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=27) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "list: Tighten" in narrow_plain + assert "[quote]" in narrow_plain + + +def test_render_transcript_compacts_code_block_first_with_closing_text_preview(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```\n\n" + "Keep verification focused on state anchors before broadening retrieval." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=28) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "python code:" in narrow_plain + assert "build_turn_state" in narrow_plain + assert "state anchors" in narrow_plain + + +def test_render_transcript_compacts_table_first_with_code_marker_and_closing_text_preview(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "| Step | Owner | Status | Notes |\n" + "| --- | --- | --- | --- |\n" + "| transcript | codex | done | narrowed preview |\n" + "| tty | codex | next | verify quote layout |\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```\n\n" + "Keep verification focused on state anchors before broadening retrieval." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=29) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "table: Step | Owner | Status" in narrow_plain + assert "state anchors" in narrow_plain + assert "[code]" in narrow_plain + + +def test_render_transcript_compacts_code_first_then_quote_then_closing_text_on_narrow_terminal( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```\n\n" + "> Keep the first screen focused on the next action.\n" + "> Trim repeated context before showing tool details.\n\n" + "Return to the terminal after the focused rerun." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=30) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=31) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "python code:" in narrow_plain + assert "build_turn_state" in narrow_plain + assert "[quote]" in narrow_plain or "quote:" in narrow_plain + assert "terminal" in narrow_plain + assert "..." in narrow_plain + assert "Return to the terminal after the focused rerun." in wide_plain + + +def test_render_transcript_compacts_quote_first_with_word_boundary_preview_on_narrow_terminal( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "> Keep the first screen focused on the next action.\n\n" + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n\n" + "Return to the terminal after the focused rerun." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=130) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "quote: Keep...next action." in narrow_plain + assert "table: Step" in narrow_plain + assert "terminal" in narrow_plain + assert "Keep th..." not in narrow_plain + + +def test_render_transcript_compacts_code_first_with_identifier_tail_preview_on_narrow_terminal( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "```python\n" + "def build_turn_state_from_checkpoint(state):\n" + " return state\n" + "```\n\n" + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n\n" + "Return to the terminal after the focused rerun.\n\n" + "> Keep the first screen focused on the next action." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=131) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "python code: build_turn...checkpoint" in narrow_plain + assert "[table]" in narrow_plain + assert "[quote]" in narrow_plain + assert "terminal" not in narrow_plain + assert "...eckpoint" not in narrow_plain + + +def test_render_transcript_compacts_table_first_then_list_then_closing_text_on_narrow_terminal( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n" + "| followup | codex | next |\n\n" + "- Re-run focused pytest\n" + "- Capture the narrow preview\n\n" + "Return to the terminal after the focused rerun." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=32) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=33) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert "table: Step | Owner | Status" in narrow_plain + assert "list:" in narrow_plain + assert "terminal" in narrow_plain + assert "..." in narrow_plain + assert "Return to the terminal after the focused rerun." in wide_plain + + +def test_render_transcript_prefers_trailing_action_text_after_quote_and_code_on_narrow_terminal( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Start with the focused rerun so the first signal is easy to compare.\n\n" + "> Keep the first screen focused on the next action.\n\n" + "Use the narrow terminal output as the baseline before changing ordering.\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```\n\n" + "Return to the terminal after the focused rerun." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=34) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "quote:" in narrow_plain + assert "[code]" in narrow_plain or "python code:" in narrow_plain + assert "terminal" in narrow_plain + assert "changing ordering" not in narrow_plain + + +def test_render_transcript_keeps_quote_action_word_when_multiple_followup_markers_compact( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "> Keep the first screen focused on the next action.\n" + "> Trim repeated context before showing tool details.\n\n" + "Use the narrow terminal output as the baseline before changing ordering.\n\n" + "- Re-run focused pytest\n" + "- Capture the narrow preview\n\n" + "Return to the terminal after the focused rerun.\n\n" + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n" + "| followup | codex | next |\n\n" + "```python\n" + "def build_turn_state_from_checkpoint(state):\n" + " return state\n" + "```" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=134) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "quote: ...action." in narrow_plain + assert "terminal" in narrow_plain + assert "[list]" in narrow_plain + assert "[table]" in narrow_plain + assert "[code]" in narrow_plain + assert "...ction." not in narrow_plain + + +def test_render_transcript_keeps_quote_action_and_latest_markers_on_tighter_terminal( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "> Keep the first screen focused on the next action.\n\n" + "The quote should stay readable while we compress the extra details.\n\n" + "- Re-run focused pytest\n" + "- Capture the narrow preview\n\n" + "Return to the terminal after the focused rerun.\n\n" + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n\n" + "```python\n" + "def build_turn_state_from_checkpoint(state):\n" + " return state\n" + "```" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (44, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=142) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "quote: ...action." in narrow_plain + assert "terminal" in narrow_plain + assert "[table]" in narrow_plain + assert "[code]" in narrow_plain + assert "t...al...st]" not in narrow_plain + + +def test_render_transcript_prefers_trailing_action_text_after_list_and_table_on_narrow_terminal( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "Tighten the first screen before widening the transcript again.\n\n" + "- Re-run focused pytest\n" + "- Capture the narrow preview\n\n" + "Compare the summary before and after the code marker fallback.\n\n" + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n" + "| followup | codex | next |\n\n" + "Return to the terminal after the focused rerun." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=35) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "list:" in narrow_plain + assert "[table]" in narrow_plain or "table:" in narrow_plain + assert "terminal" in narrow_plain + assert "marker fallback" not in narrow_plain + + +def test_render_transcript_keeps_list_label_when_quote_closing_and_table_marker_compact( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "- Re-run focused pytest\n" + "- Capture the narrow preview\n\n" + "> Keep the first screen focused on the next action.\n\n" + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n" + "| preview | codex | next |\n\n" + "Return to the terminal after the focused rerun." + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=36) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "list:" in narrow_plain + assert "[quote]" in narrow_plain or "quote:" in narrow_plain + assert "terminal" in narrow_plain + assert "[table]" in narrow_plain + + +def test_render_transcript_keeps_quote_label_across_multiple_followup_markers( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "> Keep the first screen focused on the next action.\n\n" + "Return to the terminal after the focused rerun.\n\n" + "- Re-run focused pytest\n" + "- Capture the narrow preview\n\n" + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n" + "| preview | codex | next |\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=37) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "quote:" in narrow_plain + assert "terminal" in narrow_plain + assert "[list]" in narrow_plain + assert "[table]" in narrow_plain + assert "[code]" in narrow_plain + + +def test_render_transcript_prefers_trailing_action_text_for_structure_first_with_multiple_plain_blocks( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "> Keep the first screen focused on the next action.\n\n" + "The quote should stay visible before we compress the followup details.\n\n" + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n\n" + "Return to the terminal after the focused rerun.\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=39) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "quote:" in narrow_plain + assert "terminal" in narrow_plain + assert "[table]" in narrow_plain + assert "[code]" in narrow_plain + assert "The q...tails." not in narrow_plain + + +def test_render_transcript_prefers_trailing_action_text_for_table_first_with_multiple_plain_blocks( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n\n" + "The table should stay visible before we compress the followup details.\n\n" + "> Keep the first screen focused on the next action.\n\n" + "Return to the terminal after the focused rerun.\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=40) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "table:" in narrow_plain + assert "Step | ... | Status" in narrow_plain + assert "terminal" in narrow_plain + assert "[quote]" in narrow_plain + assert "[code]" in narrow_plain + assert "The t...tails." not in narrow_plain + + +def test_render_transcript_prefers_trailing_action_text_for_list_first_with_multiple_plain_blocks( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "- Re-run focused pytest\n" + "- Capture the narrow preview\n\n" + "The checklist should stay visible before we compress the followup details.\n\n" + "> Keep the first screen focused on the next action.\n\n" + "Return to the terminal after the focused rerun.\n\n" + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=41) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "list:" in narrow_plain + assert "terminal" in narrow_plain + assert "[quote]" in narrow_plain + assert "[table]" in narrow_plain + assert "The c...tails." not in narrow_plain + + +def test_render_transcript_keeps_list_action_head_when_marker_budget_is_tight( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "- Re-run focused pytest\n" + "- Capture the narrow preview\n\n" + "The checklist should stay visible before we compress the followup details.\n\n" + "> Keep the first screen focused on the next action.\n\n" + "Return to the terminal after the focused rerun.\n\n" + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (48, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=141) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "list: Re-run" in narrow_plain + assert "...pytest" not in narrow_plain + assert "terminal" in narrow_plain + assert "[quote]" in narrow_plain + assert "[table]" in narrow_plain + + +def test_render_transcript_keeps_table_column_shape_when_marker_budget_is_tight( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n\n" + "The table should stay visible before we compress the followup details.\n\n" + "> Keep the first screen focused on the next action.\n\n" + "Return to the terminal after the focused rerun.\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (44, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=143) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "table: Step |...| Status" in narrow_plain + assert "terminal" in narrow_plain + assert "[code]" in narrow_plain + assert "Ste...tatus" not in narrow_plain + + +def test_render_transcript_keeps_list_summary_with_quote_table_and_code_markers_compact( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "- Re-run focused pytest\n" + "- Capture the narrow preview\n\n" + "The checklist should stay visible before we compress the followup details.\n\n" + "> Keep the first screen focused on the next action.\n\n" + "Return to the terminal after the focused rerun.\n\n" + "| Step | Owner | Status |\n" + "| --- | --- | --- |\n" + "| replay | codex | done |\n" + "| preview | codex | next |\n\n" + "```python\n" + "def build_turn_state_from_checkpoint(state):\n" + " return state\n" + "```" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=42) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "list: Re-run pytest" in narrow_plain + assert "terminal" in narrow_plain + assert "[quote]" in narrow_plain + assert "[table]" in narrow_plain + assert "[code]" in narrow_plain + assert "Re...est" not in narrow_plain + + +def test_render_transcript_compacts_plain_intro_before_structured_followups( + monkeypatch, +) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="assistant", + body=( + "We should keep the terminal focused on the narrow replay while we tighten the summary.\n\n" + "First trim the intro, then preserve the strongest structured signal.\n\n" + "> Keep the first screen focused on the next action.\n\n" + "- Re-run focused pytest\n" + "- Capture the narrow preview\n\n" + "```python\n" + "def build_turn_state(state):\n" + " return state\n" + "```" + ), + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=38) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + assert "replay" in narrow_plain + assert "quote:" in narrow_plain + assert "[list]" in narrow_plain + assert "[code]" in narrow_plain + assert "We...." not in narrow_plain + + +def test_render_transcript_running_tool_preview_tracks_terminal_width(monkeypatch) -> None: + entries = [ + TranscriptEntry( + id=1, + kind="tool", + body="Inspecting src/minicode/agent_loop.py for orchestration flow\nsecond detail line\nthird detail line", + toolName="read_file", + status="running", + ) + ] + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + narrow = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=7) + narrow_plain = re.sub(r"\x1b\[[0-9;]*m", "", narrow) + + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (120, 20)) + wide = transcript_module.render_transcript(entries, scroll_offset=0, window_size=8, revision=7) + wide_plain = re.sub(r"\x1b\[[0-9;]*m", "", wide) + + assert ".py" in narrow_plain + assert "orchestration flo" in narrow_plain + assert "..." in narrow_plain + assert "second detail line" not in narrow_plain + assert "second detail line" in wide_plain + + +def test_render_transcript_narrow_tool_preview_preserves_filename(monkeypatch) -> None: + monkeypatch.setattr(transcript_module, "_cached_terminal_size", lambda: (56, 20)) + + rendered = transcript_module.render_transcript( + [ + TranscriptEntry( + id=1, + kind="tool", + body=r"Reading path=C:\Users\question\projects\minicode\src\very_long_file_name.py for context before patching", + toolName="read_file", + status="running", + ) + ], + scroll_offset=0, + window_size=8, + revision=8, + ) + plain = re.sub(r"\x1b\[[0-9;]*m", "", rendered) + + assert ".py for context before patching" in plain + assert r"C:\Users\question\projects\minicode\src\very_long_file_name.py" not in plain + assert "..." in plain + + def test_error_tool_entry_stays_expanded_for_visibility() -> None: entry = TranscriptEntry(id=1, kind="tool", body="boom", toolName="run_command", status="running") _apply_tool_result_visual_state(entry, "run_command", "boom", is_error=True) @@ -155,7 +1573,7 @@ def handle_input(*_args, **_kwargs): def test_tty_input_passes_and_persists_context_manager(tmp_path, monkeypatch) -> None: captured: dict = {} saved: list[ContextManager] = [] - context_manager = ContextManager(model="default", context_window=1000) + context_manager = ContextManager(messages=[], context_window=1000) def fake_run_agent_turn(**kwargs): captured.update(kwargs) @@ -183,3 +1601,160 @@ def fake_run_agent_turn(**kwargs): assert captured["context_manager"] is context_manager assert saved == [context_manager] assert state.agent_result["messages"][-1] == {"role": "assistant", "content": "done"} + + +def test_tool_entry_elapsed_is_tracked_per_entry() -> None: + pending_tool_started_at: dict[int, float] = {} + + input_handler_module._record_tool_entry_start( + pending_tool_started_at, + 11, + started_at=100.0, + ) + input_handler_module._record_tool_entry_start( + pending_tool_started_at, + 22, + started_at=103.0, + ) + + first_elapsed = input_handler_module._consume_tool_entry_elapsed( + pending_tool_started_at, + 11, + finished_at=104.4, + ) + second_elapsed = input_handler_module._consume_tool_entry_elapsed( + pending_tool_started_at, + 22, + finished_at=104.4, + ) + + assert first_elapsed == " (4.4s)" + assert second_elapsed == " (1.4s)" + assert pending_tool_started_at == {} + + +def test_build_tool_display_output_uses_ascii_recovery_hint() -> None: + rendered = input_handler_module._build_tool_display_output( + "No such file or directory: missing.txt", + True, + ) + + assert rendered.startswith("ERROR: No such file or directory: missing.txt") + assert "Hint: file not found. Try /ls to inspect available paths." in rendered + + +def test_tty_input_keeps_progress_tools_and_await_user_in_terminal_order(tmp_path, monkeypatch) -> None: + saved: list[ContextManager] = [] + + def fake_run_agent_turn(**kwargs): + kwargs["on_progress_message"]("Scanning project") + kwargs["on_tool_start"]("read_file", {"path": "a.py"}) + kwargs["on_tool_start"]("list_files", {"path": "."}) + kwargs["on_tool_result"]("read_file", "FILE: a.py\nprint('hi')", False) + kwargs["on_tool_result"]("list_files", "a.py\nb.py", False) + kwargs["on_assistant_message"]("Need approval") + return [*kwargs["messages"], {"role": "assistant", "content": "Need approval"}] + + monkeypatch.setattr(input_handler_module, "run_agent_turn", fake_run_agent_turn) + monkeypatch.setattr(input_handler_module, "save_context_state", saved.append, raising=False) + + state = ScreenState(input="Inspect repo", cursor_offset=11) + context_manager = ContextManager(messages=[], context_window=1000) + args = TtyAppArgs( + runtime={"model": "default"}, + tools=ToolRegistry([]), + model=object(), + messages=[{"role": "system", "content": "sys"}], + cwd=str(tmp_path), + permissions=PermissionManager(str(tmp_path)), + context_manager=context_manager, + ) + + assert input_handler_module._handle_input(args, state, lambda: None) is False + state.agent_thread.join(timeout=5) + + transcript_kinds = [entry.kind for entry in state.transcript] + assert transcript_kinds == ["user", "progress", "tool", "tool", "assistant"] + assert state.transcript[1].body == "Scanning project" + assert state.transcript[2].toolName == "read_file" + assert state.transcript[2].status == "success" + assert state.transcript[3].toolName == "list_files" + assert state.transcript[3].status == "success" + assert state.transcript[4].body == "Need approval" + assert all(entry.status != "running" for entry in state.transcript if entry.kind == "tool") + assert state.status is None + assert saved == [context_manager] + + +def test_tty_input_replays_concurrent_await_user_without_dangling_tool_entries(tmp_path, monkeypatch) -> None: + def fake_run_agent_turn(**kwargs): + kwargs["on_tool_start"]("ask_user", {"question": "Approve?"}) + kwargs["on_tool_start"]("list_files", {"path": "."}) + kwargs["on_tool_result"]("ask_user", "Need approval", False) + kwargs["on_tool_result"]("list_files", "a.py\nb.py", False) + kwargs["on_assistant_message"]("Need approval") + return [*kwargs["messages"], {"role": "assistant", "content": "Need approval"}] + + monkeypatch.setattr(input_handler_module, "run_agent_turn", fake_run_agent_turn) + state = ScreenState(input="Inspect repo", cursor_offset=11) + args = TtyAppArgs( + runtime={"model": "default"}, + tools=ToolRegistry([]), + model=object(), + messages=[{"role": "system", "content": "sys"}], + cwd=str(tmp_path), + permissions=PermissionManager(str(tmp_path)), + context_manager=ContextManager(messages=[], context_window=1000), + ) + + assert input_handler_module._handle_input(args, state, lambda: None) is False + state.agent_thread.join(timeout=5) + + transcript_kinds = [entry.kind for entry in state.transcript] + assert transcript_kinds == ["user", "tool", "tool", "assistant"] + assert [entry.toolName for entry in state.transcript if entry.kind == "tool"] == [ + "ask_user", + "list_files", + ] + assert all(entry.status == "success" for entry in state.transcript if entry.kind == "tool") + assert state.transcript[-1].body == "Need approval" + assert state.status is None + assert state.active_tool is None + assert all("running" not in entry.body.lower() for entry in state.transcript if entry.kind == "tool") + + +def test_tty_input_tracks_concurrent_tool_status_without_flicker(tmp_path, monkeypatch) -> None: + status_snapshots: list[tuple[str | None, str | None]] = [] + + def fake_run_agent_turn(**kwargs): + kwargs["on_tool_start"]("ask_user", {"question": "Approve?"}) + kwargs["on_tool_start"]("list_files", {"path": "."}) + kwargs["on_tool_result"]("ask_user", "Need approval", False) + kwargs["on_tool_result"]("list_files", "a.py\nb.py", False) + return [*kwargs["messages"], {"role": "assistant", "content": "done"}] + + monkeypatch.setattr(input_handler_module, "run_agent_turn", fake_run_agent_turn) + monkeypatch.setattr(input_handler_module, "save_context_state", lambda *_args, **_kwargs: None, raising=False) + + state = ScreenState(input="Inspect repo", cursor_offset=11) + args = TtyAppArgs( + runtime={"model": "default"}, + tools=ToolRegistry([]), + model=object(), + messages=[{"role": "system", "content": "sys"}], + cwd=str(tmp_path), + permissions=PermissionManager(str(tmp_path)), + context_manager=ContextManager(messages=[], context_window=1000), + ) + + def capture_status() -> None: + status_snapshots.append((state.status, state.active_tool)) + + assert input_handler_module._handle_input(args, state, capture_status) is False + state.agent_thread.join(timeout=5) + + assert ("Running ask_user...", "ask_user") in status_snapshots + assert ("2 tool(s) running...", "2 tool(s)") in status_snapshots + assert ("Running list_files...", "list_files") in status_snapshots + assert state.status is None + assert state.active_tool is None diff --git a/py-src/tests/test_turn_kernel.py b/py-src/tests/test_turn_kernel.py new file mode 100644 index 0000000..e3e6926 --- /dev/null +++ b/py-src/tests/test_turn_kernel.py @@ -0,0 +1,558 @@ +from minicode.task_object import TaskState +from minicode.turn_kernel import ( + AssistantMessageReplay, + AssistantTurnDecision, + DeferredToolReplay, + ToolBatchPlan, + ToolReplayPlan, + ToolResultDecision, + ToolResultReplay, + ToolStepFeedback, + TurnCodaSummary, + apply_read_dedup_to_tool_result, + apply_tool_step_feedback, + build_assistant_turn_replay, + build_step_content_replay, + build_tool_batch_plan, + build_deferred_tool_replay, + build_tool_replay_plan, + build_tool_result_context_output, + build_tool_result_decision, + build_tool_result_replay, + build_turn_coda_summary, + build_tool_step_feedback, + collect_conflicting_failed_tool_names, + TurnPreludeState, + TurnRecurrentState, + decide_assistant_turn, + finalize_work_chain_task, +) + + +def test_turn_prelude_state_defaults() -> None: + prelude = TurnPreludeState() + + assert prelude.task is None + assert prelude.task_metadata == {} + assert prelude.layered_context is None + assert prelude.context_builder is None + assert prelude.auditor is None + + +def test_turn_recurrent_state_tracks_retries_and_errors() -> None: + state = TurnRecurrentState(max_steps=2) + + assert state.has_remaining_steps() + assert state.begin_step() == 1 + assert state.step == 1 + assert state.can_retry_empty_response() + assert state.can_retry_recoverable_thinking() + + state.record_empty_response_retry() + state.record_recoverable_thinking_retry() + state.record_tool_result(ok=False) + + assert state.saw_tool_result is True + assert state.empty_response_retry_count == 1 + assert state.recoverable_thinking_retry_count == 1 + assert state.tool_error_count == 1 + assert state.final_task_state() is TaskState.FAILED + + assert state.begin_step() == 2 + assert state.has_remaining_steps() is False + + +def test_turn_recurrent_state_successful_completion() -> None: + state = TurnRecurrentState(max_steps=None) + + state.record_tool_result(ok=True) + + assert state.saw_tool_result is True + assert state.tool_error_count == 0 + assert state.final_task_state() is TaskState.COMPLETED + + +def test_decide_assistant_turn_retries_recoverable_pause() -> None: + state = TurnRecurrentState(max_steps=3) + + decision = decide_assistant_turn( + turn_state=state, + step_content="", + step_kind=None, + stop_reason="pause_turn", + block_types=None, + ignored_block_types=["thinking"], + is_empty=True, + treat_as_progress=False, + is_recoverable_thinking_stop=True, + format_diagnostics=lambda *_: "", + nudge_continue="continue", + nudge_after_tool_result="after tool", + resume_after_pause="resume pause", + resume_after_max_tokens="resume max", + nudge_after_empty_response="empty after tool", + nudge_after_empty_no_tools="empty no tool", + ) + + assert decision.kind == "progress" + assert decision.assistant_content == "Model returned pause_turn; requesting the next step." + assert decision.user_content == "resume pause" + assert state.recoverable_thinking_retry_count == 1 + + +def test_decide_assistant_turn_builds_fallback_after_empty_tool_response() -> None: + state = TurnRecurrentState(max_steps=3, saw_tool_result=True, tool_error_count=2) + state.empty_response_retry_count = 2 + + decision = decide_assistant_turn( + turn_state=state, + step_content="", + step_kind=None, + stop_reason="max_tokens", + block_types=["tool"], + ignored_block_types=["thinking"], + is_empty=True, + treat_as_progress=False, + is_recoverable_thinking_stop=False, + format_diagnostics=lambda stop, blocks, ignored: ( + f" Diagnostics: stop_reason={stop}; blocks={','.join(blocks or [])}; " + f"ignored={','.join(ignored or [])}." + ), + nudge_continue="continue", + nudge_after_tool_result="after tool", + resume_after_pause="resume pause", + resume_after_max_tokens="resume max", + nudge_after_empty_response="empty after tool", + nudge_after_empty_no_tools="empty no tool", + ) + + assert decision.kind == "fallback" + assert "2 tool error(s)" in (decision.assistant_content or "") + assert "stop_reason=max_tokens" in (decision.assistant_content or "") + + +def test_build_assistant_turn_replay_keeps_progress_then_nudge_order() -> None: + replay = build_assistant_turn_replay( + decision=AssistantTurnDecision( + kind="progress", + assistant_content="working", + user_content="continue now", + ) + ) + + assert replay == AssistantMessageReplay( + callback_kind="progress", + callback_content="working", + transcript_messages=[ + {"role": "assistant_progress", "content": "working"}, + {"role": "user", "content": "continue now"}, + ], + should_return=False, + protect_final_answer=False, + ) + + +def test_build_step_content_replay_returns_after_plain_assistant_content() -> None: + replay = build_step_content_replay( + content="done", + content_kind=None, + has_calls=False, + nudge_continue="continue now", + ) + + assert replay == AssistantMessageReplay( + callback_kind="assistant", + callback_content="done", + transcript_messages=[{"role": "assistant", "content": "done"}], + should_return=True, + protect_final_answer=False, + ) + + +def test_build_tool_step_feedback_normalizes_metrics() -> None: + state = TurnRecurrentState(max_steps=5, tool_error_count=2, step=4) + + feedback = build_tool_step_feedback( + turn_state=state, + context_usage=0.75, + oscillation_index=0.2, + ) + + assert feedback == ToolStepFeedback( + error_rate=0.5, + context_usage=0.75, + avg_latency=8.0, + oscillation_index=0.2, + ) + + +def test_apply_tool_step_feedback_drives_controllers() -> None: + feedback = ToolStepFeedback( + error_rate=0.5, + context_usage=0.25, + avg_latency=6.0, + oscillation_index=0.1, + ) + healing_logs: list[str] = [] + + class FakeDecouplingController: + def __init__(self) -> None: + self.measurement = None + self.computed = False + + def record_measurement(self, measurement) -> None: + self.measurement = measurement + + def compute_decoupling_matrix(self) -> None: + self.computed = True + + class FakeHealingAction: + def __init__(self, strategy: str) -> None: + self.strategy = strategy + + class FakeSelfHealingEngine: + def __init__(self) -> None: + self.metrics = None + + def detect_and_heal(self, metrics): + self.metrics = metrics + return [FakeHealingAction("compact")] + + decoupling = FakeDecouplingController() + healing = FakeSelfHealingEngine() + + apply_tool_step_feedback( + feedback=feedback, + decoupling_controller=decoupling, + self_healing_engine=healing, + log_healing=healing_logs.append, + ) + + assert decoupling.measurement == { + "token_usage_to_latency": (0.25, 0.1), + "context_pressure_to_errors": (0.25, 0.5), + } + assert decoupling.computed is True + assert healing.metrics == { + "error_rate": 0.5, + "context_usage": 0.25, + "oscillation_index": 0.1, + } + assert healing_logs == ["compact"] + + +def test_build_tool_result_context_output_appends_retry_nudge() -> None: + state = TurnRecurrentState(max_steps=4, tool_error_count=2, step=2) + + output = build_tool_result_context_output( + turn_state=state, + tool_name="shell", + result_output="boom", + ok=False, + classify_error=lambda output, tool_name: { + "output": output, + "tool_name": tool_name, + }, + generate_nudge=lambda classified, retry_count: ( + f"retry {retry_count} for {classified['tool_name']}" + ), + ) + + assert output == "boom\n\n[System note: retry 2 for shell]" + + +def test_build_tool_result_decision_returns_transcript_and_await_user() -> None: + decision = build_tool_result_decision( + call={"id": "call-1"}, + tool_name="ask_user", + tool_input={"question": "continue?"}, + result_output="Need approval", + is_error=False, + await_user=True, + ) + + assert decision == ToolResultDecision( + tool_result_content="Need approval", + transcript_messages=[ + { + "role": "assistant_tool_call", + "toolUseId": "call-1", + "toolName": "ask_user", + "input": {"question": "continue?"}, + }, + { + "role": "tool_result", + "toolUseId": "call-1", + "toolName": "ask_user", + "content": "Need approval", + "isError": False, + }, + ], + assistant_content="Need approval", + should_return=True, + ) + + +def test_build_tool_result_replay_normalizes_callback_and_transcript() -> None: + class FakeDedupManager: + def __init__(self) -> None: + self.registered = [] + + def should_dedup(self, file_path: str, content: str) -> bool: + return file_path == "a.py" and content == "same content" + + def get_stub(self, file_path: str) -> str: + return f"stub:{file_path}" + + def register_read(self, file_path: str, content: str, message_index: int) -> None: + self.registered.append((file_path, content, message_index)) + + dedup = FakeDedupManager() + turn_state = TurnRecurrentState(max_steps=4) + log_messages: list[str] = [] + call = {"id": "call-1", "toolName": "read_file", "input": {"path": "a.py"}} + result = type( + "R", + (), + {"ok": True, "output": "same content", "awaitUser": False}, + )() + + replay = build_tool_result_replay( + call=call, + result=result, + turn_state=turn_state, + total_call_count=2, + is_concurrency_safe=True, + all_results=[(call, result)], + dedup_manager=dedup, + message_index=5, + classify_error=lambda output, tool_name: { + "output": output, + "tool_name": tool_name, + }, + generate_nudge=lambda classified, retry_count: ( + f"retry {retry_count} for {classified['tool_name']}" + ), + log_dedup=log_messages.append, + ) + + assert replay == ToolResultReplay( + callback_output="same content", + should_emit_callback=True, + should_increment_tool_calls=True, + conflicting_tool_names=[], + tool_decision=ToolResultDecision( + tool_result_content="stub:a.py", + transcript_messages=[ + { + "role": "assistant_tool_call", + "toolUseId": "call-1", + "toolName": "read_file", + "input": {"path": "a.py"}, + }, + { + "role": "tool_result", + "toolUseId": "call-1", + "toolName": "read_file", + "content": "stub:a.py", + "isError": False, + }, + ], + assistant_content=None, + should_return=False, + ), + ) + assert turn_state.saw_tool_result is True + assert dedup.registered == [("a.py", "stub:a.py", 5)] + assert log_messages == ["a.py"] + + +def test_build_deferred_tool_replay_only_for_parallel_safe_tools() -> None: + replay = build_deferred_tool_replay( + is_concurrency_safe=True, + total_call_count=2, + ) + no_replay = build_deferred_tool_replay( + is_concurrency_safe=False, + total_call_count=2, + ) + + assert replay == DeferredToolReplay(should_replay=True) + assert no_replay == DeferredToolReplay(should_replay=False) + + +def test_build_tool_batch_plan_restores_model_call_order() -> None: + calls = [ + {"id": "1", "toolName": "read_file", "input": {"path": "a.py"}}, + {"id": "2", "toolName": "write_file", "input": {"path": "b.py"}}, + {"id": "3", "toolName": "list_files", "input": {"path": "."}}, + {"id": "4", "toolName": "edit_file", "input": {"path": "c.py"}}, + ] + worker_inputs: list[list[str]] = [] + + plan = build_tool_batch_plan( + calls=calls, + concurrent_calls=[calls[2], calls[0]], + serial_calls=[calls[3], calls[1]], + get_recommended_max_workers=lambda ordered_calls: ( + worker_inputs.append([call["id"] for call in ordered_calls]) or 3 + ), + ) + + assert plan == ToolBatchPlan( + concurrent_calls=[calls[0], calls[2]], + serial_calls=[calls[1], calls[3]], + max_workers=3, + ) + assert worker_inputs == [["1", "3"]] + + +def test_build_tool_replay_plan_preserves_call_order_and_deferred_starts() -> None: + calls = [ + {"id": "1", "toolName": "read_file", "input": {"path": "a.py"}}, + {"id": "2", "toolName": "write_file", "input": {"path": "b.py"}}, + {"id": "3", "toolName": "list_files", "input": {"path": "."}}, + ] + all_results = [ + (calls[2], type("R", (), {"ok": True})()), + (calls[0], type("R", (), {"ok": True})()), + (calls[1], type("R", (), {"ok": True})()), + ] + + plan = build_tool_replay_plan( + calls=calls, + all_results=all_results, + is_concurrency_safe=lambda tool_name: tool_name in {"read_file", "list_files"}, + ) + + assert plan == ToolReplayPlan( + ordered_results=[ + (calls[0], all_results[1][1]), + (calls[1], all_results[2][1]), + (calls[2], all_results[0][1]), + ], + deferred_start_calls=[calls[0], calls[2]], + ) + + +def test_collect_conflicting_failed_tool_names_returns_other_failures() -> None: + conflicts = collect_conflicting_failed_tool_names( + call_id="1", + ok=False, + all_results=[ + ({"id": "1", "toolName": "read_file"}, type("R", (), {"ok": False})()), + ({"id": "2", "toolName": "grep_files"}, type("R", (), {"ok": False})()), + ({"id": "3", "toolName": "ls"}, type("R", (), {"ok": True})()), + ], + ) + + assert conflicts == ["grep_files"] + + +def test_apply_read_dedup_to_tool_result_replaces_duplicate_reads() -> None: + log_messages: list[str] = [] + + class FakeDedupManager: + def __init__(self) -> None: + self.registered = [] + + def should_dedup(self, file_path: str, content: str) -> bool: + return file_path == "a.py" and content == "same content" + + def get_stub(self, file_path: str) -> str: + return f"stub:{file_path}" + + def register_read(self, file_path: str, content: str, message_index: int) -> None: + self.registered.append((file_path, content, message_index)) + + dedup = FakeDedupManager() + + result = apply_read_dedup_to_tool_result( + dedup_manager=dedup, + tool_name="read_file", + tool_input={"path": "a.py"}, + result_output="same content", + ok=True, + message_index=5, + log_dedup=log_messages.append, + ) + + assert result == "stub:a.py" + assert dedup.registered == [("a.py", "stub:a.py", 5)] + assert log_messages == ["a.py"] + + +def test_build_turn_coda_summary_normalizes_final_metrics() -> None: + state = TurnRecurrentState(max_steps=5, tool_error_count=1, step=4) + + summary = build_turn_coda_summary( + turn_state=state, + context_usage=0.4, + ) + + assert summary == TurnCodaSummary( + step=4, + tool_error_count=1, + success=False, + result_summary="Turn completed: 4 steps, 1 errors", + error_rate=0.25, + avg_latency=8.0, + context_usage=0.4, + task_state=TaskState.FAILED, + ) + + +def test_finalize_work_chain_task_updates_task_and_auditor() -> None: + class FakeTask: + def __init__(self) -> None: + self.state = None + self.result_summary = "" + self.error_message = "tool failed" + + def set_state(self, state: TaskState) -> None: + self.state = state + + class FakeAuditor: + def __init__(self) -> None: + self.calls = [] + + def complete_decision( + self, + outcome, + confidence: float, + summary: str, + error_message: str, + ) -> None: + self.calls.append((outcome, confidence, summary, error_message)) + + task = FakeTask() + auditor = FakeAuditor() + summary = TurnCodaSummary( + step=3, + tool_error_count=1, + success=False, + result_summary="Turn completed: 3 steps, 1 errors", + error_rate=1 / 3, + avg_latency=6.0, + context_usage=0.5, + task_state=TaskState.FAILED, + ) + + finalize_work_chain_task( + task=task, + auditor=auditor, + coda_summary=summary, + success_outcome="success", + failure_outcome="failure", + ) + + assert task.state is TaskState.FAILED + assert task.result_summary == "Turn completed: 3 steps, 1 errors" + assert auditor.calls == [ + ( + "failure", + 300.0, + "Turn completed: 3 steps, 1 errors", + "tool failed", + ) + ] diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py index b106672..c144b68 100644 --- a/tests/test_agent_loop.py +++ b/tests/test_agent_loop.py @@ -1,7 +1,15 @@ from minicode.agent_loop import run_agent_turn +from minicode.model_switcher import ModelSwitcher from minicode.state import create_app_store from minicode.tooling import ToolDefinition, ToolRegistry, ToolResult -from minicode.types import AgentStep, ChatMessage, ModelAdapter, StepDiagnostics +from minicode.types import ( + AgentStep, + ChatMessage, + ModelAdapter, + RuntimeEvent, + StepDiagnostics, +) +from types import SimpleNamespace class ScriptedModel(ModelAdapter): @@ -24,6 +32,29 @@ def next(self, messages: list[ChatMessage], on_stream_chunk=None, store=None) -> return AgentStep(type="assistant", content="done") +class ProviderUnavailableModel(ModelAdapter): + model_id = "deepseek-v4-pro[1m]" + + def next(self, messages: list[ChatMessage], on_stream_chunk=None, store=None) -> AgentStep: + raise RuntimeError("No available channel for model deepseek-v4-pro[1m] under group cc") + + +class NamedProviderUnavailableModel(ModelAdapter): + def __init__(self, model_id: str) -> None: + self.model_id = model_id + + def next(self, messages: list[ChatMessage], on_stream_chunk=None, store=None) -> AgentStep: + raise RuntimeError(f"No available channel for model {self.model_id} under group cc") + + +class UnnamedProviderUnavailableModel(ModelAdapter): + def __init__(self, error_model_id: str) -> None: + self.error_model_id = error_model_id + + def next(self, messages: list[ChatMessage], on_stream_chunk=None, store=None) -> AgentStep: + raise RuntimeError(f"No available channel for model {self.error_model_id} under group cc") + + def test_agent_turn_executes_tool_and_returns_assistant() -> None: def run_echo(input_data: dict, _context) -> ToolResult: return ToolResult(ok=True, output=f"echo:{input_data['text']}") @@ -194,3 +225,487 @@ def test_agent_turn_passes_store_to_provider_adapter() -> None: assert messages[-1] == {"role": "assistant", "content": "done"} assert model.received_store is store + + +def test_single_deep_runtime_profile_allows_extra_turn_budget() -> None: + def run_echo(input_data: dict, _context) -> ToolResult: + return ToolResult(ok=True, output=f"echo:{input_data['text']}") + + registry = ToolRegistry( + [ + ToolDefinition( + name="echo", + description="echo tool", + input_schema={"type": "object"}, + validator=lambda value: value, + run=run_echo, + ) + ] + ) + model = ScriptedModel( + [ + AgentStep( + type="tool_calls", + calls=[{"id": "1", "toolName": "echo", "input": {"text": "hi"}}], + ), + AgentStep(type="assistant", content="done"), + ] + ) + + messages = run_agent_turn( + model=model, + tools=registry, + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "continue the durable state repair"}, + ], + cwd=".", + max_steps=1, + runtime={"runtimeProfile": "single-deep"}, + ) + + assert messages[-1] == {"role": "assistant", "content": "done"} + assert model.calls == 2 + + +def test_single_deep_runtime_profile_emits_phase_progress_updates() -> None: + model = ScriptedModel( + [ + AgentStep( + type="assistant", + content="scanning the relevant files", + kind="progress", + ), + AgentStep(type="assistant", content="done"), + ] + ) + registry = ToolRegistry([]) + progress_events: list[str] = [] + runtime_events: list[RuntimeEvent] = [] + + messages = run_agent_turn( + model=model, + tools=registry, + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "repair the runtime policy"}, + ], + cwd=".", + runtime={"runtimeProfile": "single-deep"}, + on_progress_message=progress_events.append, + on_runtime_event=runtime_events.append, + ) + + assert messages[-1] == {"role": "assistant", "content": "done"} + assert any("Runtime phase: explore." in event for event in progress_events) + assert any(event.category == "phase" and event.phase == "explore" for event in runtime_events) + + +def test_agent_turn_preserves_typed_await_user_from_tool_results() -> None: + def run_gate(_input_data: dict, _context) -> ToolResult: + return ToolResult(ok=True, output="Need your approval", awaitUser=True) + + registry = ToolRegistry( + [ + ToolDefinition( + name="approval_gate", + description="approval gate", + input_schema={"type": "object"}, + validator=lambda value: value, + run=run_gate, + ) + ] + ) + model = ScriptedModel( + [ + AgentStep( + type="tool_calls", + calls=[{"id": "1", "toolName": "approval_gate", "input": {}}], + ), + ] + ) + + messages = run_agent_turn( + model=model, + tools=registry, + messages=[{"role": "system", "content": "sys"}], + cwd=".", + ) + + assert messages[-1] == {"role": "assistant", "content": "Need your approval"} + + +def test_single_deep_runtime_transitions_into_widened_mode() -> None: + model = ScriptedModel( + [ + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content=""), + AgentStep(type="assistant", content=""), + AgentStep(type="assistant", content=""), + AgentStep(type="assistant", content=""), + AgentStep(type="assistant", content="done with a broader plan"), + ] + ) + registry = ToolRegistry([]) + progress_events: list[str] = [] + runtime_events: list[RuntimeEvent] = [] + + messages = run_agent_turn( + model=model, + tools=registry, + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "repair the runtime policy"}, + ], + cwd=".", + runtime={"runtimeProfile": "single-deep"}, + on_progress_message=progress_events.append, + on_runtime_event=runtime_events.append, + ) + + assert messages[-1] == {"role": "assistant", "content": "done with a broader plan"} + assert any("wider search" in event.lower() or "widened mode" in event.lower() for event in progress_events) + assert any("escalation trigger" in event.lower() for event in progress_events) + assert any(event.category == "widening" for event in runtime_events) + assert any( + event.category == "stop" and event.stop_reason == "done" + for event in runtime_events + ) + assert any( + message["role"] == "user" and "Switch to widened mode" in message["content"] + for message in messages + ) + + +def test_single_deep_verify_phase_blocks_unsupported_final_until_evidence_is_cited() -> None: + def run_echo(input_data: dict, _context) -> ToolResult: + return ToolResult(ok=True, output=f"pytest: {input_data['suite']} passed") + + registry = ToolRegistry( + [ + ToolDefinition( + name="echo", + description="echo tool", + input_schema={"type": "object"}, + validator=lambda value: value, + run=run_echo, + ) + ] + ) + model = ScriptedModel( + [ + AgentStep( + type="tool_calls", + calls=[{"id": "1", "toolName": "echo", "input": {"suite": "reader_probe"}}], + ), + AgentStep( + type="assistant", + content="I have enough context now.", + kind="progress", + ), + AgentStep(type="assistant", content="Done, the fix is complete."), + AgentStep( + type="assistant", + content="Verified with pytest: reader_probe passed, so the fix is complete.", + ), + ] + ) + progress_events: list[str] = [] + runtime_events: list[RuntimeEvent] = [] + + messages = run_agent_turn( + model=model, + tools=registry, + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "repair the runtime policy"}, + ], + cwd=".", + runtime={"runtimeProfile": "single-deep"}, + on_progress_message=progress_events.append, + on_runtime_event=runtime_events.append, + ) + + assert messages[-1] == { + "role": "assistant", + "content": "Verified with pytest: reader_probe passed, so the fix is complete.", + } + assert any("verification guard" in event.lower() for event in progress_events) + assert any(event.category == "guard" for event in runtime_events) + assert any( + event.category == "stop" and event.stop_reason == "done" + for event in runtime_events + ) + assert any( + message["role"] == "user" and "strict verification mode" in message["content"] + for message in messages + ) + + +def test_agent_turn_switches_to_fallback_model_on_provider_channel_error(monkeypatch) -> None: + registry = ToolRegistry([]) + runtime_events: list[RuntimeEvent] = [] + seen: dict[str, str] = {} + + def _fake_create_model_adapter(model: str, tools, runtime=None, force_mock: bool = False): + seen["model"] = model + fallback_model = ScriptedModel([AgentStep(type="assistant", content="ok via fallback")]) + fallback_model.model_id = model + return fallback_model + + monkeypatch.setenv("ANTHROPIC_MODEL_FALLBACKS", "qwen3.6-plus") + monkeypatch.setattr( + "minicode.model_switcher.build_provider_config", + lambda model, runtime=None: SimpleNamespace(api_key="test-key"), + ) + monkeypatch.setattr("minicode.model_switcher.create_model_adapter", _fake_create_model_adapter) + + messages = run_agent_turn( + model=ProviderUnavailableModel(), + tools=registry, + messages=[{"role": "system", "content": "sys"}], + cwd=".", + runtime={}, + on_runtime_event=runtime_events.append, + ) + + assert messages[-1] == {"role": "assistant", "content": "ok via fallback"} + assert seen["model"] == "qwen3.6-plus" + assert any(event.category == "recovery" and "qwen3.6-plus" in event.message for event in runtime_events) + + +def test_agent_turn_does_not_bounce_between_failed_provider_fallback_models(monkeypatch) -> None: + registry = ToolRegistry([]) + runtime_events: list[RuntimeEvent] = [] + created_models: list[str] = [] + + def _failing_create_model_adapter(model: str, tools, runtime=None, force_mock: bool = False): + created_models.append(model) + return NamedProviderUnavailableModel(model) + + monkeypatch.setenv("ANTHROPIC_MODEL_FALLBACKS", "claude-haiku-3-20240307") + monkeypatch.setattr( + "minicode.model_switcher.build_provider_config", + lambda model, runtime=None: SimpleNamespace(api_key="test-key"), + ) + monkeypatch.setattr("minicode.model_switcher.create_model_adapter", _failing_create_model_adapter) + + messages = run_agent_turn( + model=NamedProviderUnavailableModel("deepseek-v4-pro[1m]"), + tools=registry, + messages=[{"role": "system", "content": "sys"}], + cwd=".", + runtime={}, + on_runtime_event=runtime_events.append, + ) + + assert "provider availability failure" in messages[-1]["content"].lower() + assert "not a local retry loop" in messages[-1]["content"].lower() + assert "active channel:" in messages[-1]["content"].lower() + assert "next step:" in messages[-1]["content"].lower() + assert "add fallbackmodels or " in messages[-1]["content"].lower() + assert "to enable model failover" in messages[-1]["content"].lower() + assert created_models[0] == "claude-haiku-3-20240307" + assert "deepseek-v4-pro[1m]" not in created_models + assert len(created_models) == len(set(created_models)) + assert any(event.category == "recovery" for event in runtime_events) + + +def test_agent_turn_respects_runtime_anthropic_family_model_overrides(monkeypatch) -> None: + registry = ToolRegistry([]) + created_models: list[str] = [] + + def _failing_create_model_adapter(model: str, tools, runtime=None, force_mock: bool = False): + created_models.append(model) + return NamedProviderUnavailableModel(model) + + monkeypatch.delenv("ANTHROPIC_MODEL_FALLBACKS", raising=False) + monkeypatch.setattr( + "minicode.model_switcher.build_provider_config", + lambda model, runtime=None: SimpleNamespace( + api_key="test-key" + if model.startswith("claude") or model == "deepseek-v4-pro[1m]" + else "" + ), + ) + monkeypatch.setattr("minicode.model_switcher.create_model_adapter", _failing_create_model_adapter) + + messages = run_agent_turn( + model=NamedProviderUnavailableModel("deepseek-v4-pro[1m]"), + tools=registry, + messages=[{"role": "system", "content": "sys"}], + cwd=".", + runtime={ + "anthropicDefaultSonnetModel": "deepseek-v4-pro[1m]", + "anthropicDefaultOpusModel": "deepseek-v4-pro[1m]", + "anthropicDefaultHaikuModel": "deepseek-v4-pro[1m]", + }, + ) + + assert "provider availability failure" in messages[-1]["content"].lower() + assert created_models == [] + + +def test_model_switcher_uses_snapshotted_anthropic_family_overrides_when_runtime_mutates(monkeypatch) -> None: + registry = ToolRegistry([]) + runtime = { + "model": "deepseek-v4-pro[1m]", + "anthropicDefaultSonnetModel": "deepseek-v4-pro[1m]", + "anthropicDefaultOpusModel": "deepseek-v4-pro[1m]", + "anthropicDefaultHaikuModel": "deepseek-v4-pro[1m]", + } + + monkeypatch.delenv("ANTHROPIC_MODEL_FALLBACKS", raising=False) + monkeypatch.setattr( + "minicode.model_switcher.build_provider_config", + lambda model, runtime=None: SimpleNamespace( + api_key="test-key" + if model.startswith("claude") or model == "deepseek-v4-pro[1m]" + else "" + ), + ) + + switcher = ModelSwitcher( + current_model="deepseek-v4-pro[1m]", + current_runtime=runtime, + current_tools=registry, + ) + switcher.record_runtime_failure("deepseek-v4-pro[1m]") + + runtime.pop("anthropicDefaultSonnetModel") + runtime.pop("anthropicDefaultOpusModel") + runtime.pop("anthropicDefaultHaikuModel") + + assert switcher._fallback_candidates() == [] + + +def test_model_switcher_defaults_blank_anthropic_family_overrides_to_current_non_claude_model(monkeypatch) -> None: + registry = ToolRegistry([]) + runtime = {"model": "deepseek-v4-pro[1m]"} + + monkeypatch.delenv("ANTHROPIC_MODEL_FALLBACKS", raising=False) + monkeypatch.setattr( + "minicode.model_switcher.build_provider_config", + lambda model, runtime=None: SimpleNamespace( + api_key="test-key" + if model.startswith("claude") or model == "deepseek-v4-pro[1m]" + else "" + ), + ) + + switcher = ModelSwitcher( + current_model="deepseek-v4-pro[1m]", + current_runtime=runtime, + current_tools=registry, + ) + switcher.record_runtime_failure("deepseek-v4-pro[1m]") + + assert switcher._fallback_candidates() == [] + + +def test_agent_turn_infers_active_runtime_model_when_adapter_has_no_model_id(monkeypatch) -> None: + registry = ToolRegistry([]) + created_models: list[str] = [] + + def _failing_create_model_adapter(model: str, tools, runtime=None, force_mock: bool = False): + created_models.append(model) + return NamedProviderUnavailableModel(model) + + monkeypatch.delenv("ANTHROPIC_MODEL_FALLBACKS", raising=False) + monkeypatch.setattr( + "minicode.model_switcher.build_provider_config", + lambda model, runtime=None: SimpleNamespace( + api_key="test-key" + if model.startswith("claude") or model == "deepseek-v4-pro[1m]" + else "" + ), + ) + monkeypatch.setattr("minicode.model_switcher.create_model_adapter", _failing_create_model_adapter) + + messages = run_agent_turn( + model=UnnamedProviderUnavailableModel("deepseek-v4-pro[1m]"), + tools=registry, + messages=[{"role": "system", "content": "sys"}], + cwd=".", + runtime={"model": "deepseek-v4-pro[1m]"}, + ) + + assert "provider availability failure" in messages[-1]["content"].lower() + assert "deepseek-v4-pro[1m] failed" in messages[-1]["content"].lower() + assert created_models == [] + + +def test_model_switcher_prefers_runtime_configured_fallback_models(monkeypatch) -> None: + registry = ToolRegistry([]) + runtime = { + "model": "claude-sonnet-4-20250514", + "fallbackModels": ["gpt-4o"], + } + created_models: list[str] = [] + + monkeypatch.delenv("MINI_CODE_MODEL_FALLBACKS", raising=False) + monkeypatch.delenv("OPENAI_MODEL_FALLBACKS", raising=False) + monkeypatch.setattr( + "minicode.model_switcher.build_provider_config", + lambda model, runtime=None: SimpleNamespace( + api_key="test-key" if model == "gpt-4o" else "" + ), + ) + monkeypatch.setattr( + "minicode.model_switcher.create_model_adapter", + lambda model, tools, runtime=None, force_mock=False: created_models.append(model) or object(), + ) + + switcher = ModelSwitcher( + current_model="claude-sonnet-4-20250514", + current_runtime=runtime, + current_tools=registry, + ) + result = switcher.switch_to_fallback(reason="provider_outage") + + assert result.success is True + assert result.new_model == "gpt-4o" + assert switcher.current_model == "gpt-4o" + assert created_models == ["gpt-4o"] + + +def test_agent_turn_uses_default_runtime_fallback_chain_without_explicit_configuration(monkeypatch) -> None: + registry = ToolRegistry([]) + runtime_events: list[RuntimeEvent] = [] + seen: dict[str, str] = {} + + def _fake_create_model_adapter(model: str, tools, runtime=None, force_mock: bool = False): + seen["model"] = model + fallback_model = ScriptedModel([AgentStep(type="assistant", content="ok via default fallback")]) + fallback_model.model_id = model + return fallback_model + + monkeypatch.delenv("MINI_CODE_MODEL_FALLBACKS", raising=False) + monkeypatch.delenv("ANTHROPIC_MODEL_FALLBACKS", raising=False) + monkeypatch.setattr( + "minicode.model_switcher.build_provider_config", + lambda model, runtime=None: SimpleNamespace( + api_key="test-key" if model in {"gpt-4o", "gpt-4o-mini", "deepseek-v4-pro[1m]"} else "" + ), + ) + monkeypatch.setattr("minicode.model_switcher.create_model_adapter", _fake_create_model_adapter) + + messages = run_agent_turn( + model=ProviderUnavailableModel(), + tools=registry, + messages=[{"role": "system", "content": "sys"}], + cwd=".", + runtime={ + "openaiApiKey": "openai-key", + "openaiBaseUrl": "https://api.openai.com", + }, + on_runtime_event=runtime_events.append, + ) + + assert messages[-1] == {"role": "assistant", "content": "ok via default fallback"} + assert seen["model"] == "gpt-4o" + assert any(event.category == "recovery" and "gpt-4o" in event.message for event in runtime_events) diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 6697ea9..a3753ca 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -1,6 +1,9 @@ import json +import urllib.error -from minicode.anthropic_adapter import AnthropicModelAdapter +import pytest +from minicode.anthropic_adapter import AnthropicModelAdapter, _messages_endpoint +from minicode.model_registry import create_model_adapter from minicode.tooling import ToolDefinition, ToolRegistry @@ -71,3 +74,64 @@ def test_anthropic_adapter_parses_final_text(monkeypatch) -> None: assert step.content == "done" assert step.kind == "final" + +def test_messages_endpoint_normalizes_base_url_variants() -> None: + assert _messages_endpoint("https://api.anthropic.com") == "https://api.anthropic.com/v1/messages" + assert _messages_endpoint("https://proxy.example.com/v1") == "https://proxy.example.com/v1/messages" + assert _messages_endpoint("https://proxy.example.com/v1/messages") == "https://proxy.example.com/v1/messages" + + +def test_anthropic_adapter_uses_normalized_messages_endpoint(monkeypatch) -> None: + payload = { + "stop_reason": "end_turn", + "content": [{"type": "text", "text": "<final>ok</final>"}], + } + seen: dict[str, str] = {} + + def _fake_urlopen(request, timeout=60): + seen["url"] = request.full_url + return DummyResponse(payload) + + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + adapter = AnthropicModelAdapter( + {"model": "claude", "baseUrl": "https://proxy.example.com/v1", "authToken": "x"}, + _tool_registry(), + ) + + step = adapter.next([{"role": "system", "content": "sys"}, {"role": "user", "content": "finish"}]) + + assert step.type == "assistant" + assert seen["url"] == "https://proxy.example.com/v1/messages" + + +def test_anthropic_adapter_surfaces_underlying_url_error(monkeypatch) -> None: + monkeypatch.setattr( + "urllib.request.urlopen", + lambda request, timeout=60: (_ for _ in ()).throw(urllib.error.URLError("connection refused")), + ) + monkeypatch.setenv("MINI_CODE_MAX_RETRIES", "0") + adapter = AnthropicModelAdapter( + {"model": "claude", "baseUrl": "https://proxy.example.com/v1", "authToken": "x"}, + _tool_registry(), + ) + + with pytest.raises(RuntimeError, match="connection refused"): + adapter.next([{"role": "system", "content": "sys"}, {"role": "user", "content": "finish"}]) + + +def test_create_model_adapter_overrides_stale_anthropic_runtime_model() -> None: + runtime = { + "model": "deepseek-v4-pro[1m]", + "baseUrl": "https://proxy.example.com/v1", + "authToken": "x", + } + + adapter = create_model_adapter( + "claude-haiku-3-20240307", + tools=_tool_registry(), + runtime=runtime, + ) + + assert isinstance(adapter, AnthropicModelAdapter) + assert adapter.runtime["model"] == "claude-haiku-3-20240307" + diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 1ce2dcc..b481ed2 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -1,5 +1,26 @@ from minicode.cli_commands import find_matching_slash_commands, format_slash_commands, try_handle_local_command from minicode.local_tool_shortcuts import parse_local_tool_shortcut +from minicode.session import FileCheckpoint, SessionData, SessionMetadata + + +def _write_extension_manifest(root, *, name: str, enabled: bool = True, version: str = "1.0.0"): + extension_dir = root / name + extension_dir.mkdir(parents=True, exist_ok=True) + manifest_path = extension_dir / "extension.json" + manifest_path.write_text( + ( + "{\n" + f' "name": "{name}",\n' + f' "version": "{version}",\n' + ' "description": "Local helper bundle",\n' + f' "enabled": {"true" if enabled else "false"},\n' + ' "entrypoint": "bundle.py"\n' + "}\n" + ), + encoding="utf-8", + ) + (extension_dir / "bundle.py").write_text("print('ok')\n", encoding="utf-8") + return manifest_path def test_find_matching_slash_commands_returns_help_variants() -> None: @@ -51,6 +72,462 @@ def test_format_slash_commands_includes_history_and_retry() -> None: assert "/history" in commands assert "/retry" in commands assert "/cybernetics" in commands + assert "/session" in commands + assert "/session-replay" in commands + assert "/sessions" in commands + assert "/checkpoints" in commands + assert "/rewind-preview" in commands + assert "/rewind" in commands + assert "/session-rewind-preview" in commands + assert "/session-rewind" in commands + + +def test_session_command_returns_active_session_inspect() -> None: + session = SessionData( + session_id="session-1234", + created_at=1.0, + updated_at=2.0, + workspace="D:/repo", + transcript_entries=[{"kind": "assistant", "body": "done"}], + ) + session.metadata.runtime_summary = "phase:verify@2 -> stop:done@3" + + result = try_handle_local_command("/session", session=session) + + assert result is not None + assert "Session inspect: session-" in result + assert "Runtime: phase:verify@2 -> stop:done@3" in result + assert "Recent transcript" in result + + +def test_product_surface_commands_use_active_session_snapshot() -> None: + session = SessionData( + session_id="session-1234", + created_at=1.0, + updated_at=2.0, + workspace="D:/repo", + instruction_layers=[ + { + "name": "project-managed", + "scope": "project", + "kind": "managed", + "path": "D:/repo/.mini-code/MANAGED.md", + "exists": True, + "preview": "Prefer verification-first delivery.", + } + ], + hook_status={ + "total_hooks": 2, + "enabled_hooks": 1, + "total_calls": 4, + "total_duration_ms": 18, + "failure_count": 1, + "last_status": "error", + "last_error": "pytest failed", + }, + delegated_tasks=[ + {"label": "lint-worker", "status": "running"}, + ], + delegation_status={ + "running_tasks": 1, + "total_tracked": 2, + "max_slots": 4, + "available_slots": 3, + "active_labels": ["lint-worker"], + }, + extension_manifests=[ + { + "name": "git-helpers", + "scope": "project", + "enabled": True, + "version": "1.2.0", + "description": "Extra git shortcuts", + "entrypoint": "extensions/git_helpers.py", + } + ], + readiness_report={ + "status": "warning", + "provider": "anthropic-compatible", + "provider_ready": True, + "provider_channel": "anthropic-compatible via baseUrl/authToken", + "fallback_ready": False, + "fallback_candidates": ["qwen3.6-plus", "gpt-4o"], + "viable_fallbacks": ["gpt-4o"], + "fallback_guidance": [ + "Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken.", + "Add fallbackModels or anthropicFallbackModels to enable model failover.", + ], + "issues": ["Fallback 'qwen3.6-plus' is not locally ready: Missing provider channel"], + }, + ) + session.update_metadata() + + instructions = try_handle_local_command("/instructions", session=session, cwd="D:/repo") + hooks = try_handle_local_command("/hooks", session=session, cwd="D:/repo") + delegation = try_handle_local_command("/delegation", session=session, cwd="D:/repo") + extensions = try_handle_local_command("/extensions", session=session, cwd="D:/repo") + readiness = try_handle_local_command("/readiness", session=session, cwd="D:/repo") + + assert instructions is not None + assert "Instruction surface:" in instructions + assert "project/managed: active" in instructions + assert "Prefer verification-first delivery." in instructions + + assert hooks is not None + assert "Hook surface:" in hooks + assert "Failures: 1" in hooks + assert "Last error: pytest failed" in hooks + + assert delegation is not None + assert "Delegation surface:" in delegation + assert "Tracked task details" in delegation + assert "lint-worker [running]" in delegation + + assert extensions is not None + assert "Extension surface:" in extensions + assert "git-helpers [project, enabled] v1.2.0" in extensions + assert "entrypoint: extensions/git_helpers.py" in extensions + + assert readiness is not None + assert "Readiness surface:" in readiness + assert "Provider ready: yes" in readiness + assert "Fallback ready: no" in readiness + assert "Channel: anthropic-compatible via baseUrl/authToken" in readiness + assert "Configured fallbacks (1/2 locally ready):" in readiness + assert "- qwen3.6-plus [not-ready]" in readiness + assert "- gpt-4o [ready]" in readiness + assert "Guidance:" in readiness + assert "single anthropic-compatible channel" in readiness + assert "Missing provider channel" in readiness + + +def test_extension_inspect_command_reads_project_manifest(tmp_path, monkeypatch) -> None: + workspace = tmp_path / "workspace" + project_extensions = workspace / ".mini-code" / "extensions" + project_extensions.mkdir(parents=True) + _write_extension_manifest(project_extensions, name="git-helpers", enabled=True, version="1.2.3") + global_extensions = tmp_path / "global-extensions" + global_extensions.mkdir() + monkeypatch.setattr("minicode.product_surfaces.MINI_CODE_EXTENSIONS_DIR", global_extensions) + + result = try_handle_local_command("/extension-inspect git-helpers", cwd=str(workspace)) + + assert result is not None + assert "Extension inspect: git-helpers" in result + assert "Scope: project" in result + assert "Enabled: yes" in result + assert "Entrypoint exists: yes" in result + + +def test_extension_enable_and_disable_commands_update_manifest(tmp_path, monkeypatch) -> None: + workspace = tmp_path / "workspace" + project_extensions = workspace / ".mini-code" / "extensions" + project_extensions.mkdir(parents=True) + manifest_path = _write_extension_manifest( + project_extensions, + name="git-helpers", + enabled=False, + ) + global_extensions = tmp_path / "global-extensions" + global_extensions.mkdir() + monkeypatch.setattr("minicode.product_surfaces.MINI_CODE_EXTENSIONS_DIR", global_extensions) + + enabled = try_handle_local_command("/extension-enable git-helpers", cwd=str(workspace)) + assert enabled is not None + assert "Extension project:git-helpers is now enabled." in enabled + assert '"enabled": true' in manifest_path.read_text(encoding="utf-8") + + disabled = try_handle_local_command("/extension-disable project:git-helpers", cwd=str(workspace)) + assert disabled is not None + assert "Extension project:git-helpers is now disabled." in disabled + assert '"enabled": false' in manifest_path.read_text(encoding="utf-8") + + +def test_extension_inspect_requires_scope_when_names_are_ambiguous(tmp_path, monkeypatch) -> None: + workspace = tmp_path / "workspace" + project_extensions = workspace / ".mini-code" / "extensions" + project_extensions.mkdir(parents=True) + _write_extension_manifest(project_extensions, name="git-helpers", enabled=True) + global_extensions = tmp_path / "global-extensions" + global_extensions.mkdir() + _write_extension_manifest(global_extensions, name="git-helpers", enabled=False) + monkeypatch.setattr("minicode.product_surfaces.MINI_CODE_EXTENSIONS_DIR", global_extensions) + + result = try_handle_local_command("/extension-inspect git-helpers", cwd=str(workspace)) + + assert result is not None + assert "Multiple extensions matched 'git-helpers'." in result + assert "global:git-helpers" in result + assert "project:git-helpers" in result + + +def test_checkpoints_command_returns_active_session_checkpoints() -> None: + session = SessionData( + session_id="session-1234", + created_at=1.0, + updated_at=2.0, + workspace="D:/repo", + checkpoints=[ + FileCheckpoint( + checkpoint_id="abc123456789", + created_at=3.0, + file_path="D:/repo/demo.txt", + existed=True, + previous_content="before", + ) + ], + ) + session.update_metadata() + + result = try_handle_local_command("/checkpoints", session=session) + + assert result is not None + assert "Checkpoints for session session-" in result + assert "[abc12345]" in result + assert "demo.txt" in result + + +def test_sessions_command_lists_saved_workspace_sessions(tmp_path, monkeypatch) -> None: + workspace = str(tmp_path.resolve()) + other_workspace = str((tmp_path / "other").resolve()) + monkeypatch.setattr( + "minicode.cli_commands.list_sessions", + lambda: [ + SessionMetadata( + session_id="aaa111111111", + created_at=1.0, + updated_at=2.0, + first_message="alpha", + message_count=2, + workspace=workspace, + ), + SessionMetadata( + session_id="bbb222222222", + created_at=3.0, + updated_at=4.0, + first_message="beta", + message_count=3, + workspace=other_workspace, + ), + ], + raising=False, + ) + + result = try_handle_local_command("/sessions", cwd=workspace) + + assert result is not None + assert "Saved sessions:" in result + assert "aaa11111" in result + assert "bbb22222" not in result + + +def test_session_command_latest_uses_workspace_session(tmp_path, monkeypatch) -> None: + workspace = str(tmp_path.resolve()) + session = SessionData( + session_id="latest-12345", + created_at=1.0, + updated_at=2.0, + workspace=workspace, + transcript_entries=[{"kind": "assistant", "body": "restored"}], + ) + monkeypatch.setattr( + "minicode.cli_commands.get_latest_session", + lambda workspace=None: session if workspace == str(tmp_path.resolve()) else None, + raising=False, + ) + + result = try_handle_local_command("/session latest", cwd=workspace) + + assert result is not None + assert "Session inspect: latest-1" in result + assert "restored" in result + + +def test_session_replay_command_latest_uses_workspace_session(tmp_path, monkeypatch) -> None: + workspace = str(tmp_path.resolve()) + session = SessionData( + session_id="latest-12345", + created_at=1.0, + updated_at=2.0, + workspace=workspace, + history=["continue with runtime trace"], + transcript_entries=[{"kind": "assistant", "body": "restored"}], + ) + monkeypatch.setattr( + "minicode.cli_commands.get_latest_session", + lambda workspace=None: session if workspace == str(tmp_path.resolve()) else None, + raising=False, + ) + + result = try_handle_local_command("/session-replay latest", cwd=workspace) + + assert result is not None + assert "Session replay: latest-1" in result + assert "Prompt history (1 shown):" in result + assert "continue with runtime trace" in result + + +def test_checkpoints_command_latest_uses_workspace_session(tmp_path, monkeypatch) -> None: + workspace = str(tmp_path.resolve()) + session = SessionData( + session_id="latest-12345", + created_at=1.0, + updated_at=2.0, + workspace=workspace, + checkpoints=[ + FileCheckpoint( + checkpoint_id="abc123456789", + created_at=3.0, + file_path=str(tmp_path / "demo.txt"), + existed=True, + previous_content="before", + ) + ], + ) + session.update_metadata() + monkeypatch.setattr( + "minicode.cli_commands.get_latest_session", + lambda workspace=None: session if workspace == str(tmp_path.resolve()) else None, + raising=False, + ) + + result = try_handle_local_command("/checkpoints latest", cwd=workspace) + + assert result is not None + assert "Checkpoints for session latest-1" in result + assert "[abc12345]" in result + + +def test_rewind_command_rewinds_active_session(monkeypatch) -> None: + checkpoint = FileCheckpoint( + checkpoint_id="abc123456789", + created_at=3.0, + file_path="D:/repo/demo.txt", + existed=True, + previous_content="before", + ) + session = SessionData( + session_id="session-1234", + created_at=1.0, + updated_at=2.0, + workspace="D:/repo", + checkpoints=[checkpoint], + ) + session.update_metadata() + + def fake_rewind(session_arg, *, steps=1, checkpoint_id=None): + assert session_arg is session + assert steps == 1 + assert checkpoint_id is None + session_arg.checkpoints = [] + session_arg.update_metadata() + return [checkpoint] + + monkeypatch.setattr("minicode.cli_commands.rewind_session_data", fake_rewind) + + result = try_handle_local_command("/rewind", session=session) + + assert result is not None + assert "Rewound 1 checkpoint(s) for session session-" in result + assert "Restored: [abc12345] demo.txt" in result + assert "Resuming session session-" in result + + +def test_rewind_preview_command_shows_active_session_plan() -> None: + checkpoint = FileCheckpoint( + checkpoint_id="abc123456789", + created_at=3.0, + file_path="D:/repo/demo.txt", + existed=True, + previous_content="before", + ) + session = SessionData( + session_id="session-1234", + created_at=1.0, + updated_at=2.0, + workspace="D:/repo", + checkpoints=[checkpoint], + ) + session.update_metadata() + + result = try_handle_local_command("/rewind-preview", session=session) + + assert result is not None + assert "Rewind preview for session session-" in result + assert "Would restore 1 checkpoint(s) across 1 file(s)." in result + assert "Type: edit" in result + + +def test_session_rewind_command_rewinds_saved_workspace_session(tmp_path, monkeypatch) -> None: + workspace = str(tmp_path.resolve()) + session = SessionData( + session_id="latest-12345", + created_at=1.0, + updated_at=2.0, + workspace=workspace, + ) + checkpoint = FileCheckpoint( + checkpoint_id="abc123456789", + created_at=3.0, + file_path=str(tmp_path / "demo.txt"), + existed=True, + previous_content="before", + ) + session.checkpoints = [checkpoint] + session.update_metadata() + monkeypatch.setattr( + "minicode.cli_commands.get_latest_session", + lambda workspace=None: session if workspace == str(tmp_path.resolve()) else None, + raising=False, + ) + + def fake_rewind(session_id, *, steps=1, checkpoint_id=None): + assert session_id == session.session_id + assert steps == 1 + assert checkpoint_id is None + session.checkpoints = [] + session.update_metadata() + return session, [checkpoint] + + monkeypatch.setattr("minicode.cli_commands.rewind_session", fake_rewind) + + result = try_handle_local_command("/session-rewind latest", cwd=workspace) + + assert result is not None + assert "Rewound 1 checkpoint(s) for session latest-1" in result + assert "Restored: [abc12345] demo.txt" in result + assert "Resuming session latest-1" in result + + +def test_session_rewind_preview_command_uses_saved_workspace_session(tmp_path, monkeypatch) -> None: + workspace = str(tmp_path.resolve()) + session = SessionData( + session_id="latest-12345", + created_at=1.0, + updated_at=2.0, + workspace=workspace, + ) + checkpoint = FileCheckpoint( + checkpoint_id="abc123456789", + created_at=3.0, + file_path=str(tmp_path / "demo.txt"), + existed=True, + previous_content="before", + ) + session.checkpoints = [checkpoint] + session.update_metadata() + monkeypatch.setattr( + "minicode.cli_commands.get_latest_session", + lambda workspace=None: session if workspace == str(tmp_path.resolve()) else None, + raising=False, + ) + + result = try_handle_local_command("/session-rewind-preview latest", cwd=workspace) + + assert result is not None + assert "Rewind preview for session latest-1" in result + assert "Would restore 1 checkpoint(s) across 1 file(s)." in result + assert "demo.txt" in result def test_memory_command_uses_current_workspace(tmp_path) -> None: diff --git a/tests/test_config.py b/tests/test_config.py index 60d1ab1..fa2a289 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,11 @@ -from minicode.config import merge_settings, validate_provider_runtime +import minicode.config as config_module +from minicode.config import ( + default_model_fallbacks, + effective_model_fallbacks, + load_runtime_config, + merge_settings, + validate_provider_runtime, +) def test_merge_settings_merges_env_and_mcp_servers() -> None: @@ -48,3 +55,135 @@ def test_validate_provider_runtime_accepts_openrouter_prefixed_model() -> None: ) assert errors == [] + + +def test_load_runtime_config_includes_runtime_profile(monkeypatch) -> None: + monkeypatch.setattr( + config_module, + "load_effective_settings", + lambda cwd=None: { + "model": "anthropic/claude-sonnet-4", + "runtimeProfile": "single-deep", + "env": {"ANTHROPIC_API_KEY": "test-key"}, + }, + ) + monkeypatch.delenv("MINI_CODE_RUNTIME_PROFILE", raising=False) + monkeypatch.delenv("MINI_CODE_MODEL", raising=False) + monkeypatch.delenv("ANTHROPIC_MODEL", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + runtime = load_runtime_config(cwd=".") + + assert runtime["runtimeProfile"] == "single-deep" + + +def test_load_runtime_config_includes_anthropic_family_defaults(monkeypatch) -> None: + monkeypatch.setattr( + config_module, + "load_effective_settings", + lambda cwd=None: { + "model": "deepseek-v4-pro[1m]", + "env": { + "ANTHROPIC_AUTH_TOKEN": "test-token", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro[1m]", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-pro[1m]", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v4-pro[1m]", + }, + }, + ) + monkeypatch.delenv("MINI_CODE_MODEL", raising=False) + monkeypatch.delenv("ANTHROPIC_MODEL", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.delenv("ANTHROPIC_DEFAULT_SONNET_MODEL", raising=False) + monkeypatch.delenv("ANTHROPIC_DEFAULT_OPUS_MODEL", raising=False) + monkeypatch.delenv("ANTHROPIC_DEFAULT_HAIKU_MODEL", raising=False) + + runtime = load_runtime_config(cwd=".") + + assert runtime["anthropicDefaultSonnetModel"] == "deepseek-v4-pro[1m]" + assert runtime["anthropicDefaultOpusModel"] == "deepseek-v4-pro[1m]" + assert runtime["anthropicDefaultHaikuModel"] == "deepseek-v4-pro[1m]" + + +def test_load_runtime_config_includes_structured_fallback_models(monkeypatch) -> None: + monkeypatch.setattr( + config_module, + "load_effective_settings", + lambda cwd=None: { + "model": "claude-sonnet-4-20250514", + "fallbackModels": ["gpt-4o", "openrouter/auto"], + "anthropicFallbackModels": "qwen3.6-plus, claude-haiku-3-20240307", + "env": {"ANTHROPIC_API_KEY": "test-key"}, + }, + ) + monkeypatch.delenv("MINI_CODE_MODEL", raising=False) + monkeypatch.delenv("ANTHROPIC_MODEL", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("MINI_CODE_MODEL_FALLBACKS", raising=False) + monkeypatch.delenv("ANTHROPIC_MODEL_FALLBACKS", raising=False) + + runtime = load_runtime_config(cwd=".") + + assert runtime["fallbackModels"] == ["gpt-4o", "openrouter/auto"] + assert runtime["anthropicFallbackModels"] == [ + "qwen3.6-plus", + "claude-haiku-3-20240307", + ] + + +def test_default_model_fallbacks_seed_bounded_cross_provider_chain_for_non_claude_anthropic() -> None: + runtime = { + "model": "deepseek-v4-pro[1m]", + "openaiApiKey": "openai-key", + "openaiBaseUrl": "https://api.openai.com", + "openrouterApiKey": "openrouter-key", + "openrouterBaseUrl": "https://openrouter.ai/api/v1", + } + + assert default_model_fallbacks(runtime, "anthropic") == [ + "gpt-4o", + "gpt-4o-mini", + "openrouter/auto", + ] + + +def test_effective_model_fallbacks_prefer_explicit_before_defaults() -> None: + runtime = { + "model": "claude-sonnet-4-20250514", + "fallbackModels": ["gpt-4o"], + "anthropicDefaultHaikuModel": "claude-haiku-3-20240307", + "apiKey": "anthropic-key", + "baseUrl": "https://api.anthropic.com", + } + + assert effective_model_fallbacks(runtime, "anthropic") == [ + "gpt-4o", + "claude-haiku-3-20240307", + ] + + +def test_load_runtime_config_falls_back_to_model_for_missing_anthropic_family_defaults(monkeypatch) -> None: + monkeypatch.setattr( + config_module, + "load_effective_settings", + lambda cwd=None: { + "model": "deepseek-v4-pro[1m]", + "env": { + "ANTHROPIC_AUTH_TOKEN": "test-token", + }, + }, + ) + monkeypatch.delenv("MINI_CODE_MODEL", raising=False) + monkeypatch.delenv("ANTHROPIC_MODEL", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.delenv("ANTHROPIC_DEFAULT_SONNET_MODEL", raising=False) + monkeypatch.delenv("ANTHROPIC_DEFAULT_OPUS_MODEL", raising=False) + monkeypatch.delenv("ANTHROPIC_DEFAULT_HAIKU_MODEL", raising=False) + + runtime = load_runtime_config(cwd=".") + + assert runtime["anthropicDefaultSonnetModel"] == "deepseek-v4-pro[1m]" + assert runtime["anthropicDefaultOpusModel"] == "deepseek-v4-pro[1m]" + assert runtime["anthropicDefaultHaikuModel"] == "deepseek-v4-pro[1m]" diff --git a/tests/test_cybernetic_ablation.py b/tests/test_cybernetic_ablation.py new file mode 100644 index 0000000..7e0fd29 --- /dev/null +++ b/tests/test_cybernetic_ablation.py @@ -0,0 +1,165 @@ +import json + +from minicode.cybernetic_ablation import ( + CyberneticAblationRunner, + format_ablation_report, + load_harness_task_profiles, + load_harness_run_evidence, +) + + +def test_ablation_returns_paired_arms_for_each_task(): + data = CyberneticAblationRunner().run() + + assert data["task_count"] == 5 + assert data["arms"] == ["baseline", "cybernetic"] + assert len(data["results"]) == data["task_count"] * 2 + + task_ids = {item["task_id"] for item in data["results"]} + for task_id in task_ids: + arms = {item["arm"] for item in data["results"] if item["task_id"] == task_id} + assert arms == {"baseline", "cybernetic"} + + +def test_cybernetic_arm_improves_control_metrics(): + data = CyberneticAblationRunner().run() + delta = data["summary"]["delta_cybernetic_minus_baseline"] + + assert delta["completion_score"] > 0 + assert delta["tool_error_rate"] < 0 + assert delta["context_peak"] < 0 + assert delta["verification_strength"] > 0 + + +def test_ablation_report_can_be_written(tmp_path): + runner = CyberneticAblationRunner() + data = runner.run() + paths = runner.write_outputs(tmp_path, data) + + assert paths["json"].exists() + assert paths["markdown"].exists() + report = paths["markdown"].read_text(encoding="utf-8") + assert "MiniCode 控制论消融实验" in report + assert "Cybernetic - Baseline" in report + + +def test_report_formatter_includes_task_level_rows(): + data = CyberneticAblationRunner().run() + report = format_ablation_report(data) + + assert "debug-core-loop" in report + assert "failure-recovery" in report + assert "supervisor risk" in report + + +def test_harness_loader_maps_oracle_and_metadata_to_profiles(tmp_path): + task_dir = tmp_path / "G4-verification-trap-calibration" + task_dir.mkdir() + (task_dir / "metadata.yaml").write_text( + "\n".join([ + "id: G4-verification-trap-calibration", + "bundle: B4 Verification-Constraint Control", + "variant: calibration", + "primary_failure_labels:", + " - forbidden_shortcut_used", + " - verification_not_rerun", + " - false_completion", + ]), + encoding="utf-8", + ) + (task_dir / "oracle.json").write_text( + json.dumps({ + "key_files": ["repo/pkg/adapter.py", "repo/pkg/public_api.py"], + "forbidden": ["repo/pkg/public_api.py"], + }), + encoding="utf-8", + ) + + profiles = load_harness_task_profiles(tmp_path) + + assert len(profiles) == 1 + assert profiles[0].task_id == "G4-verification-trap-calibration" + assert profiles[0].coverage_sensitive is True + assert profiles[0].tests_passed is False + assert "repo/pkg/adapter.py" in profiles[0].changed_files + + +def test_runner_can_use_harness_profiles(tmp_path): + task_dir = tmp_path / "G5-resource-fork-counterfactual" + task_dir.mkdir() + (task_dir / "metadata.yaml").write_text( + "\n".join([ + "id: G5-resource-fork-counterfactual", + "bundle: B5 Resource-Fork Control", + "variant: counterfactual", + "primary_failure_labels:", + " - expensive_suite_overused", + " - retry_policy_not_grounded", + ]), + encoding="utf-8", + ) + (task_dir / "oracle.json").write_text( + json.dumps({"key_files": ["repo/payment/retry.py"]}), + encoding="utf-8", + ) + + data = CyberneticAblationRunner().run_from_harness(tmp_path) + + assert data["source"] == str(tmp_path) + assert data["task_count"] == 1 + assert data["summary"]["delta_cybernetic_minus_baseline"]["verification_strength"] > 0 + + +def test_results_json_evidence_is_summarized(tmp_path): + evidence_path = tmp_path / "results.json" + evidence_path.write_text( + json.dumps([ + { + "condition": "baseline", + "status": "completed", + "visible_pass": True, + "hidden_pass": False, + "grader_success": False, + "elapsed_sec": 10.0, + "diagnostic_labels": ["missing_verification"], + }, + { + "condition": "verification_gate", + "status": "completed", + "visible_pass": True, + "hidden_pass": True, + "grader_success": True, + "elapsed_sec": 12.0, + "diagnostic_labels": [], + }, + ]), + encoding="utf-8", + ) + + evidence = load_harness_run_evidence(evidence_path) + + assert evidence["schema"] == "results_json" + assert evidence["conditions"]["baseline"]["grader_success_rate"] == 0.0 + assert evidence["conditions"]["verification_gate"]["grader_success_rate"] == 1.0 + assert evidence["delta"]["cybernetic_minus_baseline"] == 1.0 + + +def test_profile_json_evidence_is_included_in_report(tmp_path): + evidence_path = tmp_path / "profile.json" + evidence_path.write_text( + json.dumps({ + "hygiene_profiles": { + "naive": {"green": 1, "red": 2, "labels": {"constraint_violation": 2}}, + "policy_full": {"green": 3, "red": 0, "labels": {}}, + }, + }), + encoding="utf-8", + ) + + data = CyberneticAblationRunner().run() + data["harness_evidence"] = load_harness_run_evidence(evidence_path) + report = format_ablation_report(data) + + assert "已有 Harness 运行证据" in report + assert "policy_full" in report + assert "+0.667" in report diff --git a/tests/test_cybernetic_supervisor.py b/tests/test_cybernetic_supervisor.py new file mode 100644 index 0000000..2fe7b0a --- /dev/null +++ b/tests/test_cybernetic_supervisor.py @@ -0,0 +1,96 @@ +from minicode.cybernetic_supervisor import ( + ControlSnapshot, + CyberneticSupervisor, + SupervisorRisk, + load_supervisor_report, + save_supervisor_report, +) +from minicode.intent_parser import ActionType, IntentType, ParsedIntent +from minicode.pipeline_engine import get_pipeline_engine +from minicode.task_object import TaskObject + + +class TestCyberneticSupervisor: + def test_empty_report_is_low_risk(self): + report = CyberneticSupervisor().report([]) + assert report.risk_level == SupervisorRisk.LOW + assert report.overall_health == 1.0 + + def test_context_snapshot_marks_high_usage_as_risky(self): + supervisor = CyberneticSupervisor() + snap = supervisor.snapshot_from_context({ + "sensor": {"current_usage": 0.92}, + "predictor": {"urgency": 0.3}, + }) + assert snap.action == "compact" + assert snap.risk == 0.92 + + def test_report_aggregates_highest_risk(self): + supervisor = CyberneticSupervisor() + report = supervisor.report([ + ControlSnapshot(name="context", health=0.2, risk=0.9, action="compact"), + ControlSnapshot(name="memory", health=0.8, risk=0.2, action="standard"), + ]) + assert report.risk_level == SupervisorRisk.CRITICAL + assert "context: compact" in report.recommended_actions + + def test_decision_snapshot_reads_progress_stall_score(self): + snap = CyberneticSupervisor().snapshot_from_decision( + "progress", + {"action": "switch_strategy", "health_score": 0.4, "stall_score": 0.6}, + ) + assert snap.action == "switch_strategy" + assert snap.risk == 0.6 + + def test_tool_decision_snapshot_tracks_parallelism_pressure(self): + snap = CyberneticSupervisor().snapshot_from_tool_decision({ + "concurrency_multiplier": 0.4, + "cooldown_seconds": 1.0, + "retry_backoff_multiplier": 2.0, + "reasons": ["high tool error rate"], + }) + assert snap.name == "tool_scheduling" + assert snap.action == "increase_retry_backoff" + assert snap.risk > 0.5 + + def test_summary_format_is_readable(self): + report = CyberneticSupervisor().report([ + ControlSnapshot(name="verification", health=0.5, risk=0.75, action="full") + ]) + summary = report.format_summary() + assert "Cybernetic Supervisor" in summary + assert "risk_level" in summary + + def test_report_persistence_roundtrip(self, tmp_path, monkeypatch): + import minicode.cybernetic_supervisor as supervisor_module + + monkeypatch.setattr( + supervisor_module, + "SUPERVISOR_STATE_PATH", + tmp_path / "cybernetic_supervisor.json", + ) + report = CyberneticSupervisor().report([ + ControlSnapshot(name="context", health=0.2, risk=0.9, action="compact") + ]) + save_supervisor_report(report) + loaded = load_supervisor_report() + assert loaded is not None + assert loaded.risk_level == report.risk_level + assert loaded.snapshots[0].name == "context" + + +class TestSupervisorPipelineIntegration: + def test_pipeline_outputs_supervisor_report(self): + task = TaskObject( + raw_input="explain code", + parsed_intent=ParsedIntent( + raw_input="explain code", + intent_type=IntentType.EXPLAIN, + action_type=ActionType.READ, + confidence=1.0, + ), + ) + result = get_pipeline_engine().run(task) + assert "cybernetic_supervisor" in result.outputs + assert "risk_level" in result.outputs["cybernetic_supervisor"] + assert "recommended_actions" in result.outputs["cybernetic_supervisor"] diff --git a/tests/test_headless.py b/tests/test_headless.py new file mode 100644 index 0000000..0cfc360 --- /dev/null +++ b/tests/test_headless.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from pathlib import Path + +from minicode.tooling import ToolRegistry +from minicode.types import AgentStep, ChatMessage, ModelAdapter + + +class _DummyPermissions: + def __init__(self, cwd: str, prompt=None) -> None: + self.cwd = cwd + self.prompt = prompt + + def get_summary(self) -> list[str]: + return ["workspace writes allowed"] + + +class _DummyMemoryManager: + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + + def get_relevant_context(self) -> dict[str, str]: + return {} + + +class _ProviderUnavailableModel(ModelAdapter): + model_id = "deepseek-v4-pro[1m]" + + def next( + self, + messages: list[ChatMessage], + on_stream_chunk=None, + store=None, + ) -> AgentStep: + raise RuntimeError( + "No available channel for model deepseek-v4-pro[1m] under group cc" + ) + + +def test_run_headless_forwards_runtime_to_agent_turn(monkeypatch, tmp_path: Path) -> None: + import minicode.headless + + runtime = { + "model": "deepseek-v4-pro[1m]", + "baseUrl": "https://openai-proxy.example/v1", + "authToken": "test-token", + } + captured: dict[str, object] = {} + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + "minicode.config.load_runtime_config", + lambda cwd: runtime, + ) + monkeypatch.setattr( + "minicode.tools.create_default_tool_registry", + lambda cwd, runtime=None: ToolRegistry([]), + ) + monkeypatch.setattr("minicode.permissions.PermissionManager", _DummyPermissions) + monkeypatch.setattr("minicode.memory.MemoryManager", _DummyMemoryManager) + monkeypatch.setattr( + "minicode.prompt.build_system_prompt", + lambda cwd, permissions, context: "sys", + ) + monkeypatch.setattr( + "minicode.model_registry.create_model_adapter", + lambda model, tools, runtime=None: object(), + ) + + def _fake_run_agent_turn(**kwargs): + captured["runtime"] = kwargs["runtime"] + return [{"role": "assistant", "content": "ok"}] + + monkeypatch.setattr("minicode.agent_loop.run_agent_turn", _fake_run_agent_turn) + + response = minicode.headless.run_headless("Reply with exactly OK.") + + assert response == "ok" + assert captured["runtime"] is runtime + + +def test_run_headless_provider_failure_uses_runtime_channel_details( + monkeypatch, + tmp_path: Path, +) -> None: + import minicode.headless + + runtime = { + "model": "deepseek-v4-pro[1m]", + "baseUrl": "https://openai-proxy.example/v1", + "authToken": "test-token", + } + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("MINI_CODE_MODEL_FALLBACKS", raising=False) + monkeypatch.delenv("ANTHROPIC_MODEL_FALLBACKS", raising=False) + monkeypatch.delenv("OPENAI_MODEL_FALLBACKS", raising=False) + monkeypatch.delenv("OPENROUTER_MODEL_FALLBACKS", raising=False) + monkeypatch.setattr( + "minicode.config.load_runtime_config", + lambda cwd: runtime, + ) + monkeypatch.setattr( + "minicode.tools.create_default_tool_registry", + lambda cwd, runtime=None: ToolRegistry([]), + ) + monkeypatch.setattr("minicode.permissions.PermissionManager", _DummyPermissions) + monkeypatch.setattr("minicode.memory.MemoryManager", _DummyMemoryManager) + monkeypatch.setattr( + "minicode.prompt.build_system_prompt", + lambda cwd, permissions, context: "sys", + ) + monkeypatch.setattr( + "minicode.model_registry.create_model_adapter", + lambda model, tools, runtime=None: _ProviderUnavailableModel(), + ) + + response = minicode.headless.run_headless("Reply with exactly OK.") + + assert "Provider availability failure:" in response + assert "Active channel: anthropic-compatible via baseUrl/authToken." in response + assert "Next step: Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken." in response + diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..66d044d --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from minicode.main import ( + _handle_inspect_session_request, + _handle_preview_rewind_request, + _handle_replay_session_request, +) +from minicode.session import create_file_checkpoint, create_new_session + + +def test_handle_inspect_session_request_prints_session_summary( + monkeypatch, + capsys, +) -> None: + session = create_new_session(workspace="/tmp/test") + create_file_checkpoint( + session, + file_path="/tmp/test/demo.txt", + existed=True, + previous_content="before", + ) + session.transcript_entries = [ + {"id": 1, "kind": "assistant", "body": "Collected evidence."}, + ] + session.update_metadata() + monkeypatch.setattr("minicode.main._resolve_target_session", lambda cwd, session_id: session) + + code = _handle_inspect_session_request("/tmp/test", "latest") + captured = capsys.readouterr() + + assert code == 0 + assert f"Session inspect: {session.session_id[:8]}" in captured.out + assert "Recent checkpoints:" in captured.out + assert "Recent transcript (1 shown):" in captured.out + + +def test_handle_inspect_session_request_errors_when_missing(monkeypatch, capsys) -> None: + monkeypatch.setattr("minicode.main._resolve_target_session", lambda cwd, session_id: None) + + code = _handle_inspect_session_request("/tmp/test", "latest") + captured = capsys.readouterr() + + assert code == 1 + assert "No saved session found for inspection." in captured.err + + +def test_handle_replay_session_request_prints_session_timeline( + monkeypatch, + capsys, +) -> None: + session = create_new_session(workspace="/tmp/test") + session.history = ["continue with runtime trace"] + session.transcript_entries = [ + {"id": 1, "kind": "assistant", "body": "Collected evidence."}, + ] + session.update_metadata() + monkeypatch.setattr("minicode.main._resolve_target_session", lambda cwd, session_id: session) + + code = _handle_replay_session_request("/tmp/test", "latest") + captured = capsys.readouterr() + + assert code == 0 + assert f"Session replay: {session.session_id[:8]}" in captured.out + assert "Prompt history (1 shown):" in captured.out + assert "Transcript timeline (1 shown):" in captured.out + + +def test_handle_preview_rewind_request_prints_rewind_plan( + monkeypatch, + capsys, +) -> None: + session = create_new_session(workspace="/tmp/test") + create_file_checkpoint( + session, + file_path="/tmp/test/demo.txt", + existed=True, + previous_content="before", + ) + session.update_metadata() + monkeypatch.setattr("minicode.main._resolve_target_session", lambda cwd, session_id: session) + + code = _handle_preview_rewind_request("/tmp/test", "latest", 1, None) + captured = capsys.readouterr() + + assert code == 0 + assert f"Rewind preview for session {session.session_id[:8]}" in captured.out + assert "Would restore 1 checkpoint(s) across 1 file(s)." in captured.out + + +def test_handle_preview_rewind_request_errors_when_missing(monkeypatch, capsys) -> None: + monkeypatch.setattr("minicode.main._resolve_target_session", lambda cwd, session_id: None) + + code = _handle_preview_rewind_request("/tmp/test", "latest", 1, None) + captured = capsys.readouterr() + + assert code == 1 + assert "No saved session found to preview." in captured.err diff --git a/tests/test_memory_curator.py b/tests/test_memory_curator.py new file mode 100644 index 0000000..bf0cdd8 --- /dev/null +++ b/tests/test_memory_curator.py @@ -0,0 +1,132 @@ +"""Tests for MemoryCuratorAgent — background memory optimization.""" +from __future__ import annotations + +import tempfile + +from minicode.memory import MemoryEntry, MemoryManager, MemoryScope, MemoryTier +from minicode.memory_curator_agent import CuratorReport, MemoryCuratorAgent + + +def _make_entry(eid, content, domains=None, tier=None, tags=None, related=None): + return MemoryEntry( + id=eid, scope=MemoryScope.PROJECT, + category="pattern", content=content, + domains=domains or [], tier=tier or MemoryTier.SHORT_TERM, + tags=tags or [], related_to=related or [], + ) + + +class TestCuratorBasics: + def test_init(self): + curator = MemoryCuratorAgent() + assert not curator.should_run + + def test_task_count_triggers(self): + curator = MemoryCuratorAgent(run_interval_tasks=3) + for _ in range(3): + curator.on_task_complete() + assert curator.should_run + + def test_run_resets_counter(self): + curator = MemoryCuratorAgent(run_interval_tasks=3) + for _ in range(5): + curator.on_task_complete() + curator._task_count = 0 # Reset manually after run (simulates run_cycle) + assert not curator.should_run + + +class TestCuratorWithMemory: + def test_collects_stats(self): + with tempfile.TemporaryDirectory() as tmp: + mgr = MemoryManager(project_root=tmp) + mgr.add_entry(MemoryScope.PROJECT, category="pattern", + content="Use React functional components") + mgr.add_entry(MemoryScope.PROJECT, category="convention", + content="FastAPI async handlers") + + curator = MemoryCuratorAgent(memory_manager=mgr) + report = curator.run_cycle(force=True) + assert report.total_entries >= 2 + assert "frontend" in report.domain_distribution or report.total_entries > 0 + + def test_archive_duplicates(self): + with tempfile.TemporaryDirectory() as tmp: + mgr = MemoryManager(project_root=tmp) + mgr.add_entry(MemoryScope.PROJECT, category="pattern", + content="Use React functional components with hooks for all new code") + mgr.add_entry(MemoryScope.PROJECT, category="pattern", + content="Use React functional components with hooks for all new code") + + curator = MemoryCuratorAgent(memory_manager=mgr, min_similarity_archive=0.9) + report = curator.run_cycle(force=True) + assert report.memories_archived >= 1 + + def test_insight_from_related_cluster(self): + with tempfile.TemporaryDirectory() as tmp: + mgr = MemoryManager(project_root=tmp) + mgr.add_entry(MemoryScope.PROJECT, category="pattern", + content="Use React with Zustand for state management") + mgr.add_entry(MemoryScope.PROJECT, category="pattern", + content="Zustand stores in src/stores/, one per feature") + mgr.add_entry(MemoryScope.PROJECT, category="pattern", + content="Migrated from Redux to Zustand Q1 2026") + + # Manually link them + entries = mgr.memories[MemoryScope.PROJECT].entries + for e in entries: + e.domains = ["frontend"] + entries[0].related_to = [entries[1].id, entries[2].id] + entries[1].related_to = [entries[0].id, entries[2].id] + entries[2].related_to = [entries[0].id, entries[1].id] + + curator = MemoryCuratorAgent(memory_manager=mgr, min_similarity_consolidate=0.3) + report = curator.run_cycle(force=True) + assert report.insights_created >= 1 + + def test_rule_based_fallback_insight(self): + with tempfile.TemporaryDirectory() as tmp: + mgr = MemoryManager(project_root=tmp) + # Clear auto-loaded entries from user scope + mgr.memories[MemoryScope.PROJECT].entries.clear() + mgr.add_entry(MemoryScope.PROJECT, category="pattern", + content="React forms use react-hook-form with zod") + mgr.add_entry(MemoryScope.PROJECT, category="pattern", + content="React form validation with zod schemas") + mgr.add_entry(MemoryScope.PROJECT, category="pattern", + content="Form components must wrap inputs with Controller") + + for e in mgr.memories[MemoryScope.PROJECT].entries: + e.domains = ["frontend"] + + curator = MemoryCuratorAgent(memory_manager=mgr, min_similarity_consolidate=0.3) + report = curator.run_cycle(force=True) + assert report.total_entries >= 3 + assert curator.get_last_report() is not None + + def test_report_to_dict(self): + report = CuratorReport( + insights_created=2, memories_archived=1, + total_entries=50, recommendations=["test"], + ) + d = report.to_dict() + assert d["insights_created"] == 2 + assert "test" in d["recommendations"] + + +class TestCuratorTierPromotion: + def test_promote_and_link_called(self): + with tempfile.TemporaryDirectory() as tmp: + mgr = MemoryManager(project_root=tmp) + # Clear auto-loaded entries + for s in MemoryScope: + mgr.memories[s].entries.clear() + for i in range(5): + mgr.add_entry(MemoryScope.PROJECT, category="pattern", + content=f"React pattern {i}: use hooks") + for e in mgr.memories[MemoryScope.PROJECT].entries: + e.domains = ["frontend"] + + curator = MemoryCuratorAgent(memory_manager=mgr, min_similarity_archive=0.3) + report = curator.run_cycle(force=True) + # Should have run promote_memories and link_memories + assert report.total_entries == 5 diff --git a/tests/test_model_selection_controller.py b/tests/test_model_selection_controller.py new file mode 100644 index 0000000..81e56ab --- /dev/null +++ b/tests/test_model_selection_controller.py @@ -0,0 +1,81 @@ +from minicode.model_registry import ( + ModelSelectionController, + ModelSelectionSignal, + ReasoningEffort, + format_model_status, + resolve_model_info, +) + + +class TestModelSelectionController: + def test_complex_task_prefers_stronger_model(self): + controller = ModelSelectionController() + decision = controller.decide( + ModelSelectionSignal(task_complexity="complex", budget_pressure=0.0) + ) + info = resolve_model_info(decision.model) + assert decision.reasoning_effort in {ReasoningEffort.HIGH, ReasoningEffort.XHIGH} + assert info.supports_tools is True + assert decision.score > 0 + + def test_high_budget_pressure_selects_cheaper_model(self): + controller = ModelSelectionController() + cheap = controller.decide( + ModelSelectionSignal(task_complexity="moderate", budget_pressure=0.9) + ) + expensive = controller.decide( + ModelSelectionSignal(task_complexity="complex", budget_pressure=0.0) + ) + + cheap_info = resolve_model_info(cheap.model) + expensive_info = resolve_model_info(expensive.model) + cheap_cost = cheap_info.pricing_input + cheap_info.pricing_output + expensive_cost = expensive_info.pricing_input + expensive_info.pricing_output + + assert cheap_cost <= expensive_cost + assert cheap.reasoning_effort in {ReasoningEffort.LOW, ReasoningEffort.MEDIUM} + assert "high budget pressure" in cheap.reasons + + def test_requires_tools_excludes_no_tools_models(self): + controller = ModelSelectionController() + decision = controller.decide( + ModelSelectionSignal(task_complexity="complex", requires_tools=True) + ) + assert resolve_model_info(decision.model).supports_tools is True + + def test_long_context_requirement_prefers_large_context(self): + controller = ModelSelectionController() + decision = controller.decide( + ModelSelectionSignal(task_complexity="moderate", requires_long_context=True) + ) + assert resolve_model_info(decision.model).context_window >= 128_000 + assert "long context required" in decision.reasons + + def test_recent_failures_raise_reasoning_effort(self): + controller = ModelSelectionController() + normal = controller.decide(ModelSelectionSignal(task_complexity="moderate")) + recovered = controller.decide( + ModelSelectionSignal(task_complexity="moderate", recent_failures=3) + ) + effort_order = { + ReasoningEffort.LOW: 0, + ReasoningEffort.MEDIUM: 1, + ReasoningEffort.HIGH: 2, + ReasoningEffort.XHIGH: 3, + } + assert effort_order[recovered.reasoning_effort] >= effort_order[normal.reasoning_effort] + assert "recent failures: 3" in recovered.reasons + + def test_decision_serializes_to_dict(self): + controller = ModelSelectionController() + decision = controller.decide(ModelSelectionSignal(task_complexity="simple")) + data = decision.to_dict() + assert data["model"] == decision.model + assert data["provider"] == decision.provider.value + assert data["reasoning_effort"] == decision.reasoning_effort.value + + def test_model_status_includes_cybernetic_recommendation(self): + status = format_model_status("gpt-4o-mini", {"openaiApiKey": "sk-test"}) + assert "Cybernetic Recommendation" in status + assert "Effort:" in status + assert "Score:" in status diff --git a/tests/test_product_surfaces.py b/tests/test_product_surfaces.py new file mode 100644 index 0000000..56b9d7d --- /dev/null +++ b/tests/test_product_surfaces.py @@ -0,0 +1,61 @@ +from minicode.product_surfaces import build_readiness_report + + +def test_build_readiness_report_surfaces_viable_fallbacks() -> None: + report = build_readiness_report( + ".", + runtime={ + "model": "claude-sonnet-4-20250514", + "apiKey": "anthropic-key", + "baseUrl": "https://api.anthropic.com", + "fallbackModels": ["gpt-4o"], + "openaiApiKey": "openai-key", + "openaiBaseUrl": "https://api.openai.com", + }, + ) + + assert report.status == "ready" + assert report.provider_ready is True + assert report.fallback_ready is True + assert report.fallback_candidates == ["gpt-4o", "claude-haiku-3-20240307"] + assert report.viable_fallbacks == ["gpt-4o", "claude-haiku-3-20240307"] + assert "fallbacks 2/2 locally ready" in report.summary + + +def test_build_readiness_report_warns_when_primary_ready_but_no_fallbacks() -> None: + report = build_readiness_report( + ".", + runtime={ + "model": "deepseek-v4-pro[1m]", + "baseUrl": "https://api.anthropic.com", + "authToken": "proxy-token", + }, + ) + + assert report.status == "warning" + assert report.provider_ready is True + assert report.provider_channel == "anthropic-compatible via baseUrl/authToken" + assert report.fallback_ready is False + assert report.fallback_candidates == [] + assert any("single anthropic-compatible channel" in item.lower() for item in report.fallback_guidance) + assert any("no local fallback credentials" in item.lower() for item in report.fallback_guidance) + assert any("no configured or default fallback models" in issue.lower() for issue in report.issues) + + +def test_build_readiness_report_uses_default_fallback_coverage() -> None: + report = build_readiness_report( + ".", + runtime={ + "model": "deepseek-v4-pro[1m]", + "apiKey": "anthropic-key", + "baseUrl": "https://api.anthropic.com", + "openaiApiKey": "openai-key", + "openaiBaseUrl": "https://api.openai.com", + }, + ) + + assert report.status == "ready" + assert report.provider_ready is True + assert report.fallback_ready is True + assert report.fallback_candidates[:2] == ["gpt-4o", "gpt-4o-mini"] + assert report.viable_fallbacks[:2] == ["gpt-4o", "gpt-4o-mini"] diff --git a/tests/test_progress_controller.py b/tests/test_progress_controller.py new file mode 100644 index 0000000..9e1d490 --- /dev/null +++ b/tests/test_progress_controller.py @@ -0,0 +1,69 @@ +from minicode.intent_parser import ActionType, IntentType, ParsedIntent +from minicode.pipeline_engine import get_pipeline_engine +from minicode.progress_controller import ( + ProgressAction, + ProgressController, + ProgressSignal, +) +from minicode.task_object import TaskObject + + +class TestProgressController: + def test_healthy_progress_continues(self): + decision = ProgressController().decide( + ProgressSignal(total_steps=5, completed_steps=3, tool_calls=3, output_changed=True) + ) + assert decision.action in {ProgressAction.CONTINUE, ProgressAction.VERIFY} + assert decision.health_score > 0.5 + + def test_output_change_requests_verification(self): + decision = ProgressController().decide( + ProgressSignal(total_steps=4, completed_steps=2, output_changed=True, tests_passed=None) + ) + assert decision.action == ProgressAction.VERIFY + + def test_stalled_execution_requests_confirmation(self): + decision = ProgressController().decide( + ProgressSignal( + total_steps=8, + completed_steps=0, + failed_steps=2, + tool_calls=8, + tool_errors=6, + output_changed=False, + max_steps=8, + ) + ) + assert decision.action == ProgressAction.REQUEST_CONFIRMATION + assert decision.stall_score >= 0.75 + + def test_completed_and_verified_stops(self): + decision = ProgressController().decide( + ProgressSignal(total_steps=4, completed_steps=4, output_changed=True, tests_passed=True) + ) + assert decision.action == ProgressAction.STOP + + def test_step_pressure_narrows_scope(self): + decision = ProgressController().decide( + ProgressSignal(total_steps=9, completed_steps=4, tool_calls=4, max_steps=10) + ) + assert decision.action == ProgressAction.NARROW_SCOPE + + +class TestProgressPipelineIntegration: + def test_pipeline_result_includes_progress_control(self): + task = TaskObject( + raw_input="explain code", + parsed_intent=ParsedIntent( + raw_input="explain code", + intent_type=IntentType.EXPLAIN, + action_type=ActionType.READ, + confidence=1.0, + ), + ) + result = get_pipeline_engine().run(task) + assert "progress_control" in result.outputs + assert result.outputs["progress_control"]["action"] in { + "continue", "verify", "stop", "narrow_scope", "switch_strategy", "request_confirmation", + } + diff --git a/tests/test_release_readiness.py b/tests/test_release_readiness.py new file mode 100644 index 0000000..cfb2e51 --- /dev/null +++ b/tests/test_release_readiness.py @@ -0,0 +1,142 @@ +from minicode.release_readiness import ( + ReleaseCheck, + classify_provider_outcome, + release_readiness_as_dict, + release_readiness_as_markdown, + summarize_release_status, +) + + +def _check(label: str, *, status: str = "passed", exit_code: int = 0, summary: str | None = None) -> ReleaseCheck: + return ReleaseCheck( + label=label, + command=f"python -m {label}", + exit_code=exit_code, + status=status, + summary=summary or f"{label} completed.", + ) + + +def test_classify_provider_outcome_detects_answered_and_provider_outage() -> None: + answered = classify_provider_outcome(exit_code=0, stdout="OK", stderr="") + outage = classify_provider_outcome( + exit_code=1, + stdout="", + stderr="Provider availability failure: all viable fallback models were unavailable.", + ) + + assert answered == ("answered", "OK") + assert outage == ("provider_outage", "Provider availability failure: all viable fallback models were unavailable.") + + +def test_summarize_release_status_treats_provider_outage_as_warning() -> None: + status = summarize_release_status( + compile_check=_check("compileall"), + test_check=_check("pytest-q"), + runtime_eval_check=_check("runtime-profile-eval"), + smoke_checks=[_check("inspect-session"), _check("replay-session")], + provider_outcomes=["provider_outage"], + readiness_report={"fallback_ready": True}, + ) + + assert status == "warning" + + +def test_summarize_release_status_escalates_provider_outage_without_fallbacks() -> None: + status = summarize_release_status( + compile_check=_check("compileall"), + test_check=_check("pytest-q"), + runtime_eval_check=_check("runtime-profile-eval"), + smoke_checks=[_check("inspect-session"), _check("replay-session")], + provider_outcomes=["provider_outage"], + readiness_report={"fallback_ready": False}, + ) + + assert status == "at-risk" + + +def test_release_readiness_outputs_include_provider_diagnostics_and_artifacts() -> None: + compile_check = _check("compileall") + test_check = _check("pytest-q") + runtime_eval_check = _check("runtime-profile-eval", summary="runtime eval completed.") + smoke_checks = [ + _check("list-sessions", summary="2 sessions listed."), + _check("preview-rewind", summary="rewind preview completed."), + ] + diagnostics = [ + { + "label": "headless-provider-smoke", + "outcome": "provider_outage", + "command": "python -m minicode.headless \"Reply with exactly OK.\"", + "exit_code": 1, + "summary": "Provider availability failure.", + "stdout": "", + "stderr": "Provider availability failure.", + } + ] + artifacts = { + "json": "benchmarks/runtime_profile_eval_results.json", + "markdown": "benchmarks/runtime_profile_eval_results.md", + } + + payload = release_readiness_as_dict( + generated_at="2026-06-05T00:00:00+00:00", + status="warning", + compile_check=compile_check, + test_check=test_check, + runtime_eval_check=runtime_eval_check, + smoke_checks=smoke_checks, + provider_diagnostics=diagnostics, + runtime_profile_artifacts=artifacts, + readiness_report={ + "provider": "anthropic", + "provider_ready": True, + "provider_channel": "anthropic-compatible via baseUrl/authToken", + "fallback_ready": True, + "fallback_candidates": ["gpt-4o"], + "viable_fallbacks": ["gpt-4o"], + "fallback_guidance": [ + "Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken.", + "Add fallbackModels or anthropicFallbackModels to enable model failover.", + ], + "summary": "readiness: ready (anthropic) [fallbacks 1/1 locally ready]", + }, + ) + rendered = release_readiness_as_markdown( + generated_at="2026-06-05T00:00:00+00:00", + status="warning", + compile_check=compile_check, + test_check=test_check, + runtime_eval_check=runtime_eval_check, + smoke_checks=smoke_checks, + provider_diagnostics=diagnostics, + runtime_profile_artifacts=artifacts, + readiness_report={ + "provider": "anthropic", + "provider_ready": True, + "provider_channel": "anthropic-compatible via baseUrl/authToken", + "fallback_ready": True, + "fallback_candidates": ["gpt-4o"], + "viable_fallbacks": ["gpt-4o"], + "fallback_guidance": [ + "Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken.", + "Add fallbackModels or anthropicFallbackModels to enable model failover.", + ], + "summary": "readiness: ready (anthropic) [fallbacks 1/1 locally ready]", + }, + ) + + assert payload["status"] == "warning" + assert payload["provider_diagnostics"][0]["outcome"] == "provider_outage" + assert payload["readiness_report"]["fallback_ready"] is True + assert payload["readiness_report"]["provider_channel"] == "anthropic-compatible via baseUrl/authToken" + assert payload["runtime_profile_artifacts"]["json"].endswith("runtime_profile_eval_results.json") + assert "## Core Gate" in rendered + assert "## Product Smokes" in rendered + assert "## Provider Diagnostics" in rendered + assert "## Provider Fallback Coverage" in rendered + assert "headless-provider-smoke" in rendered + assert "Channel: anthropic-compatible via baseUrl/authToken" in rendered + assert "Guidance:" in rendered + assert "gpt-4o" in rendered + assert "runtime_profile_eval_results.md" in rendered diff --git a/tests/test_runtime_profile_eval.py b/tests/test_runtime_profile_eval.py new file mode 100644 index 0000000..d06b00a --- /dev/null +++ b/tests/test_runtime_profile_eval.py @@ -0,0 +1,247 @@ +from minicode.runtime_profile_eval import ( + ProviderDiagnostic, + RuntimeEvalCondition, + RuntimeEvalScenario, + evaluate_runtime_profiles, + runtime_profile_eval_as_dict, + runtime_profile_eval_as_markdown, + summarize_runtime_profile_eval, +) +from minicode.tooling import ToolRegistry +from minicode.types import AgentStep, ChatMessage, ModelAdapter + + +class ScriptedModel(ModelAdapter): + def __init__(self, steps: list[AgentStep]) -> None: + self._steps = steps + self.calls = 0 + + def next(self, messages: list[ChatMessage], on_stream_chunk=None) -> AgentStep: + step = self._steps[self.calls] + self.calls += 1 + return step + + +def test_evaluate_runtime_profiles_compares_budget_floor_between_profiles() -> None: + scenario = RuntimeEvalScenario( + name="depth-budget-floor", + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "repair the runtime policy"}, + ], + model_factory=lambda: ScriptedModel( + [ + AgentStep( + type="assistant", + content="scanning the relevant files", + kind="progress", + ), + AgentStep(type="assistant", content="done"), + ] + ), + tools_factory=lambda: ToolRegistry([]), + max_steps=1, + ) + rows = evaluate_runtime_profiles( + scenarios=[scenario], + conditions=[ + RuntimeEvalCondition( + label="single", + runtime={"runtimeProfile": "single"}, + max_steps=1, + ), + RuntimeEvalCondition( + label="single-deep", + runtime={"runtimeProfile": "single-deep"}, + max_steps=1, + ), + ], + ) + + assert len(rows) == 2 + single_row = next(row for row in rows if row.condition == "single") + deep_row = next(row for row in rows if row.condition == "single-deep") + assert single_row.completed is False + assert deep_row.completed is True + assert single_row.model_calls == 1 + assert deep_row.model_calls == 2 + assert deep_row.runtime_events >= 1 + assert deep_row.stop_reason == "done" + assert deep_row.runtime_trace[-1].startswith("stop:done") + + +def test_summarize_runtime_profile_eval_counts_widened_runs() -> None: + scenario = RuntimeEvalScenario( + name="widening-escalation", + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "repair the runtime policy"}, + ], + model_factory=lambda: ScriptedModel( + [ + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content="still exploring", kind="progress"), + AgentStep(type="assistant", content=""), + AgentStep(type="assistant", content=""), + AgentStep(type="assistant", content=""), + AgentStep(type="assistant", content=""), + AgentStep(type="assistant", content="done with a broader plan"), + ] + ), + tools_factory=lambda: ToolRegistry([]), + ) + rows = evaluate_runtime_profiles( + scenarios=[scenario], + conditions=[ + RuntimeEvalCondition( + label="single-deep", + runtime={"runtimeProfile": "single-deep"}, + max_steps=1, + ), + ], + ) + + summary = summarize_runtime_profile_eval(rows) + + assert rows[0].widened is True + assert rows[0].runtime_event_counts["widening"] >= 1 + assert rows[0].stop_reason == "done" + assert any(token.startswith("widen:") for token in rows[0].runtime_trace) + assert summary["single-deep"]["runs"] == 1 + assert summary["single-deep"]["widened_runs"] == 1 + assert summary["single-deep"]["completion_rate"] == 1.0 + + +def test_runtime_profile_eval_as_markdown_renders_summary_and_rows() -> None: + scenario = RuntimeEvalScenario( + name="depth-budget-floor", + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "repair the runtime policy"}, + ], + model_factory=lambda: ScriptedModel( + [ + AgentStep( + type="assistant", + content="scanning the relevant files", + kind="progress", + ), + AgentStep(type="assistant", content="done"), + ] + ), + tools_factory=lambda: ToolRegistry([]), + max_steps=1, + ) + rows = evaluate_runtime_profiles( + scenarios=[scenario], + conditions=[ + RuntimeEvalCondition( + label="single-deep", + runtime={"runtimeProfile": "single-deep"}, + max_steps=1, + ), + ], + ) + + rendered = runtime_profile_eval_as_markdown(rows) + + assert "# Runtime Profile Eval" in rendered + assert "| condition | runs | completion_rate |" in rendered + assert "avg_runtime_events" in rendered + assert "stop_reason" in rendered + assert "## Runtime Timelines" in rendered + assert "depth-budget-floor" in rendered + assert "single-deep" in rendered + assert "phase:" in rendered + + +def test_runtime_profile_eval_as_dict_includes_runtime_trace() -> None: + scenario = RuntimeEvalScenario( + name="depth-budget-floor", + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "repair the runtime policy"}, + ], + model_factory=lambda: ScriptedModel( + [ + AgentStep( + type="assistant", + content="scanning the relevant files", + kind="progress", + ), + AgentStep(type="assistant", content="done"), + ] + ), + tools_factory=lambda: ToolRegistry([]), + max_steps=1, + ) + rows = evaluate_runtime_profiles( + scenarios=[scenario], + conditions=[ + RuntimeEvalCondition( + label="single-deep", + runtime={"runtimeProfile": "single-deep"}, + max_steps=1, + ), + ], + ) + + payload = runtime_profile_eval_as_dict(rows) + + assert payload["rows"][0]["runtime_trace"] + assert payload["rows"][0]["runtime_trace"][-1].startswith("stop:done") + + +def test_runtime_profile_eval_outputs_include_provider_diagnostics() -> None: + scenario = RuntimeEvalScenario( + name="depth-budget-floor", + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "repair the runtime policy"}, + ], + model_factory=lambda: ScriptedModel( + [ + AgentStep( + type="assistant", + content="scanning the relevant files", + kind="progress", + ), + AgentStep(type="assistant", content="done"), + ] + ), + tools_factory=lambda: ToolRegistry([]), + max_steps=1, + ) + rows = evaluate_runtime_profiles( + scenarios=[scenario], + conditions=[ + RuntimeEvalCondition( + label="single-deep", + runtime={"runtimeProfile": "single-deep"}, + max_steps=1, + ), + ], + ) + diagnostics = [ + ProviderDiagnostic( + label="headless-smoke", + outcome="provider_outage", + command="python -m minicode.headless \"Reply with exactly OK.\"", + exit_code=1, + summary="Provider availability failure: all viable fallback models were unavailable.", + stdout="", + stderr="Provider availability failure", + ) + ] + + payload = runtime_profile_eval_as_dict(rows, diagnostics) + rendered = runtime_profile_eval_as_markdown(rows, diagnostics) + + assert payload["provider_diagnostics"][0]["label"] == "headless-smoke" + assert payload["provider_diagnostics"][0]["outcome"] == "provider_outage" + assert "## Provider Diagnostics" in rendered + assert "headless-smoke" in rendered + assert "provider_outage" in rendered diff --git a/tests/test_runtime_profiles.py b/tests/test_runtime_profiles.py new file mode 100644 index 0000000..221bb3e --- /dev/null +++ b/tests/test_runtime_profiles.py @@ -0,0 +1,29 @@ +from minicode.runtime_profiles import get_runtime_profile, resolve_runtime_profile + + +def test_get_runtime_profile_defaults_to_single() -> None: + profile = get_runtime_profile(None) + + assert profile.name == "single" + assert profile.max_steps == 50 + + +def test_resolve_runtime_profile_keeps_single_deep_budget_floor() -> None: + profile = resolve_runtime_profile( + {"runtimeProfile": "single-deep"}, + fallback_max_steps=1, + ) + + assert profile.name == "single-deep" + assert profile.max_steps == 80 + assert profile.widening_step_bonus == 6 + + +def test_resolve_runtime_profile_preserves_explicit_single_budget() -> None: + profile = resolve_runtime_profile( + {"runtimeProfile": "single"}, + fallback_max_steps=12, + ) + + assert profile.name == "single" + assert profile.max_steps == 12 diff --git a/tests/test_session.py b/tests/test_session.py index 1bd147b..9afc367 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -12,14 +12,22 @@ AutosaveManager, SessionData, SessionMetadata, + _runtime_summary_from_transcript_entries, cleanup_old_sessions, + create_file_checkpoint, create_new_session, delete_session, + format_checkpoint_summary_line, + format_session_inspect, + format_session_checkpoints, format_session_list, + format_session_replay, format_session_resume, get_latest_session, list_sessions, load_session, + rewind_session_data, + rewind_session, save_session, ) @@ -75,6 +83,36 @@ def test_save_and_load_session(temp_session_dir): assert loaded.workspace == "/tmp/test" +def test_save_and_load_session_preserves_runtime_summary(temp_session_dir): + session = create_new_session(workspace="/tmp/test") + session.transcript_entries = [ + { + "id": 1, + "kind": "progress", + "category": "runtime", + "runtimeKind": "phase", + "runtimeStep": 1, + "runtimePhase": "explore", + "body": "Runtime phase: explore.", + }, + { + "id": 2, + "kind": "progress", + "category": "runtime", + "runtimeKind": "stop", + "runtimeStep": 2, + "runtimeStopReason": "done", + "body": "Turn complete.", + }, + ] + + save_session(session) + loaded = load_session(session.session_id) + + assert loaded is not None + assert loaded.metadata.runtime_summary == "phase:explore@1 -> stop:done@2" + + def test_load_nonexistent_session(temp_session_dir): """Test loading a session that doesn't exist.""" loaded = load_session("nonexistent") @@ -190,6 +228,22 @@ def test_format_session_list(temp_session_dir): assert session.session_id[:8] in result +def test_format_session_list_includes_runtime_summary(temp_session_dir): + meta = SessionMetadata( + session_id="abc123456789", + created_at=1.0, + updated_at=2.0, + first_message="hello", + message_count=3, + workspace="/tmp/test", + runtime_summary="phase:verify@2 -> stop:done@3", + ) + + result = format_session_list([meta]) + + assert "Runtime: phase:verify@2 -> stop:done@3" in result + + def test_format_session_resume(temp_session_dir): """Test formatting session info for resume.""" session = create_new_session(workspace="/tmp/test") @@ -199,3 +253,627 @@ def test_format_session_resume(temp_session_dir): assert "Resuming session" in result assert session.session_id[:8] in result assert "/tmp/test" in result + + +def test_format_session_resume_includes_runtime_summary(temp_session_dir): + session = create_new_session(workspace="/tmp/test") + session.transcript_entries = [ + { + "id": 1, + "kind": "progress", + "category": "runtime", + "runtimeKind": "phase", + "runtimeStep": 2, + "runtimePhase": "verify", + "body": "Runtime phase: verify.", + }, + { + "id": 2, + "kind": "progress", + "category": "runtime", + "runtimeKind": "stop", + "runtimeStep": 3, + "runtimeStopReason": "done", + "body": "Turn complete.", + }, + ] + session.update_metadata() + + result = format_session_resume(session) + + assert "Runtime: phase:verify@2 -> stop:done@3" in result + + +def test_save_and_load_session_preserves_checkpoints(temp_session_dir): + session = create_new_session(workspace="/tmp/test") + checkpoint = create_file_checkpoint( + session, + file_path="/tmp/test/demo.txt", + existed=True, + previous_content="before", + ) + + assert checkpoint is not None + + loaded = load_session(session.session_id) + assert loaded is not None + assert loaded.metadata.checkpoint_count == 1 + assert len(loaded.checkpoints) == 1 + assert loaded.checkpoints[0].file_path == "/tmp/test/demo.txt" + assert loaded.checkpoints[0].previous_content == "before" + + +def test_save_and_load_session_preserves_product_surfaces(temp_session_dir): + session = create_new_session(workspace="/tmp/test") + session.instruction_layers = [ + { + "name": "project-managed", + "scope": "project", + "kind": "managed", + "path": "/tmp/test/.mini-code/MANAGED.md", + "exists": True, + "preview": "Prefer strict verification.", + } + ] + session.hook_status = { + "total_hooks": 2, + "enabled_hooks": 1, + "total_calls": 3, + "total_duration_ms": 14, + } + session.delegated_tasks = [{"label": "lint-worker", "status": "running"}] + session.delegation_status = { + "running_tasks": 1, + "total_tracked": 1, + "max_slots": 4, + "available_slots": 3, + "active_labels": ["lint-worker"], + } + session.extension_manifests = [ + { + "name": "git-helpers", + "scope": "project", + "enabled": True, + "version": "1.2.0", + } + ] + session.readiness_report = { + "status": "blocked", + "provider": "anthropic-compatible", + "provider_ready": False, + "provider_channel": "anthropic-compatible via baseUrl/authToken", + "fallback_guidance": [ + "Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken.", + "Add fallbackModels or anthropicFallbackModels to enable model failover.", + ], + "issues": ["Missing provider channel"], + } + session.update_metadata() + + save_session(session) + loaded = load_session(session.session_id) + + assert loaded is not None + assert loaded.instruction_layers[0]["kind"] == "managed" + assert loaded.hook_status["total_calls"] == 3 + assert loaded.delegated_tasks[0]["label"] == "lint-worker" + assert loaded.extension_manifests[0]["name"] == "git-helpers" + assert loaded.readiness_report["provider"] == "anthropic-compatible" + assert "project-managed" in loaded.metadata.instruction_summary + assert "1 running" in loaded.metadata.delegation_summary + + +def test_rewind_session_restores_latest_file_content(temp_session_dir, tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + target = workspace / "demo.txt" + target.write_text("before", encoding="utf-8") + + session = create_new_session(workspace=str(workspace)) + create_file_checkpoint( + session, + file_path=str(target), + existed=True, + previous_content="before", + ) + target.write_text("after", encoding="utf-8") + + rewound, restored = rewind_session(session.session_id) + + assert rewound is not None + assert len(restored) == 1 + assert target.read_text(encoding="utf-8") == "before" + assert rewound.metadata.checkpoint_count == 1 + assert len(rewound.checkpoints) == 1 + assert rewound.checkpoints[0].kind == "rewind" + assert rewound.checkpoints[0].previous_content == "after" + + +def test_rewind_session_deletes_new_file_for_nonexistent_checkpoint(temp_session_dir, tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + target = workspace / "created.txt" + + session = create_new_session(workspace=str(workspace)) + create_file_checkpoint( + session, + file_path=str(target), + existed=False, + previous_content="", + ) + target.write_text("new file", encoding="utf-8") + + rewound, restored = rewind_session(session.session_id) + + assert rewound is not None + assert len(restored) == 1 + assert not target.exists() + assert rewound.metadata.checkpoint_count == 1 + assert rewound.checkpoints[0].kind == "rewind" + assert rewound.checkpoints[0].previous_content == "new file" + + +def test_rewind_session_supports_checkpoint_id(temp_session_dir, tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + target = workspace / "demo.txt" + target.write_text("v1", encoding="utf-8") + + session = create_new_session(workspace=str(workspace)) + first = create_file_checkpoint( + session, + file_path=str(target), + existed=True, + previous_content="v1", + ) + target.write_text("v2", encoding="utf-8") + second = create_file_checkpoint( + session, + file_path=str(target), + existed=True, + previous_content="v2", + ) + target.write_text("v3", encoding="utf-8") + + rewound, restored = rewind_session( + session.session_id, + checkpoint_id=first.checkpoint_id if first else None, + ) + + assert second is not None + assert rewound is not None + assert [item.checkpoint_id for item in restored] == [ + first.checkpoint_id, + second.checkpoint_id, + ] + assert target.read_text(encoding="utf-8") == "v1" + assert len(rewound.checkpoints) == 1 + assert rewound.checkpoints[0].kind == "rewind" + assert rewound.checkpoints[0].previous_content == "v3" + + +def test_rewind_session_data_restores_in_memory_session(temp_session_dir, tmp_path): + target = tmp_path / "demo.txt" + target.write_text("after", encoding="utf-8") + session = create_new_session(workspace=str(tmp_path)) + create_file_checkpoint( + session, + file_path=str(target), + existed=True, + previous_content="before", + ) + + restored = rewind_session_data(session) + + assert [item.file_path for item in restored] == [str(target)] + assert target.read_text(encoding="utf-8") == "before" + assert session.metadata.checkpoint_count == 1 + assert len(session.checkpoints) == 1 + assert session.checkpoints[0].kind == "rewind" + assert session.checkpoints[0].previous_content == "after" + + +def test_rewind_session_data_allows_undoing_a_prior_rewind(temp_session_dir, tmp_path): + target = tmp_path / "demo.txt" + target.write_text("v1", encoding="utf-8") + session = create_new_session(workspace=str(tmp_path)) + create_file_checkpoint( + session, + file_path=str(target), + existed=True, + previous_content="v1", + ) + target.write_text("v2", encoding="utf-8") + create_file_checkpoint( + session, + file_path=str(target), + existed=True, + previous_content="v2", + ) + target.write_text("v3", encoding="utf-8") + + restored = rewind_session_data(session) + + assert len(restored) == 1 + assert target.read_text(encoding="utf-8") == "v2" + assert session.checkpoints[-1].kind == "rewind" + + undo = rewind_session_data(session) + + assert len(undo) == 1 + assert undo[0].kind == "rewind" + assert target.read_text(encoding="utf-8") == "v3" + assert session.checkpoints[-1].kind == "rewind" + assert session.checkpoints[-1].previous_content == "v2" + + +def test_rewind_session_data_undoes_latest_rewind_group_atomically(temp_session_dir, tmp_path): + alpha = tmp_path / "alpha.txt" + beta = tmp_path / "beta.txt" + alpha.write_text("a1", encoding="utf-8") + beta.write_text("b1", encoding="utf-8") + session = create_new_session(workspace=str(tmp_path)) + create_file_checkpoint( + session, + file_path=str(alpha), + existed=True, + previous_content="a1", + ) + alpha.write_text("a2", encoding="utf-8") + create_file_checkpoint( + session, + file_path=str(beta), + existed=True, + previous_content="b1", + ) + beta.write_text("b2", encoding="utf-8") + + restored = rewind_session_data(session, steps=2) + + assert len(restored) == 2 + assert alpha.read_text(encoding="utf-8") == "a1" + assert beta.read_text(encoding="utf-8") == "b1" + assert len(session.checkpoints) == 2 + assert {checkpoint.kind for checkpoint in session.checkpoints} == {"rewind"} + group_ids = {checkpoint.group_id for checkpoint in session.checkpoints} + assert len(group_ids) == 1 + + undo = rewind_session_data(session) + + assert len(undo) == 2 + assert {item.kind for item in undo} == {"rewind"} + assert alpha.read_text(encoding="utf-8") == "a2" + assert beta.read_text(encoding="utf-8") == "b2" + + +def test_format_session_list_includes_checkpoint_count(temp_session_dir): + meta = SessionMetadata( + session_id="abc123456789", + created_at=1.0, + updated_at=2.0, + first_message="hello", + message_count=3, + workspace="/tmp/test", + checkpoint_count=2, + ) + + result = format_session_list([meta]) + + assert "Checkpoints: 2" in result + + +def test_format_session_resume_includes_checkpoint_count(temp_session_dir): + session = create_new_session(workspace="/tmp/test") + create_file_checkpoint( + session, + file_path="/tmp/test/demo.txt", + existed=True, + previous_content="before", + ) + + result = format_session_resume(session) + + assert "Checkpoints: 1" in result + + +def test_format_session_resume_includes_recent_checkpoints(temp_session_dir): + session = create_new_session(workspace="/tmp/test") + first = create_file_checkpoint( + session, + file_path="/tmp/test/alpha.txt", + existed=True, + previous_content="before", + ) + second = create_file_checkpoint( + session, + file_path="/tmp/test/beta.txt", + existed=True, + previous_content="before", + ) + + result = format_session_resume(session) + + assert first is not None + assert second is not None + assert "Recent checkpoints:" in result + assert f"[{second.checkpoint_id[:8]}] beta.txt" in result + assert f"[{first.checkpoint_id[:8]}] alpha.txt" in result + + +def test_format_session_checkpoints_lists_latest_first(temp_session_dir): + session = create_new_session(workspace="/tmp/test") + first = create_file_checkpoint( + session, + file_path="/tmp/test/first.txt", + existed=True, + previous_content="one", + ) + second = create_file_checkpoint( + session, + file_path="/tmp/test/second.txt", + existed=False, + previous_content="", + ) + + result = format_session_checkpoints(session) + + assert first is not None + assert second is not None + assert f"[{second.checkpoint_id[:8]}]" in result + assert f"[{first.checkpoint_id[:8]}]" in result + assert result.index(second.file_path) < result.index(first.file_path) + assert "Restores: new file" in result + assert "Type: edit" in result + + +def test_format_checkpoint_summary_line_returns_latest_checkpoint_preview(temp_session_dir): + session = create_new_session(workspace="/tmp/test") + first = create_file_checkpoint( + session, + file_path="/tmp/test/first.txt", + existed=True, + previous_content="one", + ) + second = create_file_checkpoint( + session, + file_path="/tmp/test/second.txt", + existed=False, + previous_content="", + ) + + result = format_checkpoint_summary_line(session) + + assert first is not None + assert second is not None + assert result.startswith("checkpoint-summary: 2 saved; latest ") + assert f"[{second.checkpoint_id[:8]}] second.txt" in result + assert f"[{first.checkpoint_id[:8]}] first.txt" in result + + +def test_format_checkpoint_summary_line_marks_rewind_safety_entries(temp_session_dir): + session = create_new_session(workspace="/tmp/test") + create_file_checkpoint( + session, + file_path="/tmp/test/first.txt", + existed=True, + previous_content="one", + ) + session.checkpoints.append( + type(session.checkpoints[0])( + checkpoint_id="rewind123456", + created_at=2.0, + file_path="/tmp/test/second.txt", + existed=True, + previous_content="two", + kind="rewind", + group_id="group-1", + ) + ) + session.update_metadata() + + result = format_checkpoint_summary_line(session) + + assert "[rewind12] second.txt [rewind]" in result + + +def test_format_session_inspect_includes_runtime_checkpoints_and_recent_transcript(temp_session_dir): + session = create_new_session(workspace="/tmp/test") + session.history = ["look at logs"] + session.skills = ["runtime", "rewind"] + session.mcp_servers = ["memory"] + create_file_checkpoint( + session, + file_path="/tmp/test/demo.txt", + existed=True, + previous_content="before", + ) + session.transcript_entries = [ + {"id": 1, "kind": "assistant", "body": "Initial analysis complete."}, + { + "id": 2, + "kind": "progress", + "category": "runtime", + "runtimeKind": "phase", + "runtimeStep": 4, + "runtimePhase": "verify", + "body": "Runtime phase: verify.", + }, + { + "id": 3, + "kind": "tool", + "toolName": "edit_file", + "status": "success", + "body": "Patched minicode/session.py", + }, + ] + session.update_metadata() + + result = format_session_inspect(session) + + assert f"Session inspect: {session.session_id[:8]}" in result + assert "Skills: runtime, rewind" in result + assert "MCP servers: memory" in result + assert "Checkpoints: 1" in result + assert "Runtime: phase:verify@4" in result + assert "Recent checkpoints: 1 saved; latest " in result + assert "Recent transcript (3 shown):" in result + assert "- [assistant] Initial analysis complete." in result + assert "- [runtime:phase] Runtime phase: verify." in result + assert "- [tool:edit_file/success] Patched minicode/session.py" in result + + +def test_format_session_inspect_and_replay_include_product_surfaces(temp_session_dir): + session = create_new_session(workspace="/tmp/test") + session.instruction_layers = [ + { + "name": "project-managed", + "scope": "project", + "kind": "managed", + "path": "/tmp/test/.mini-code/MANAGED.md", + "exists": True, + "preview": "Prefer strict verification.", + } + ] + session.hook_status = { + "total_hooks": 2, + "enabled_hooks": 1, + "total_calls": 4, + "total_duration_ms": 11, + "failure_count": 1, + "last_status": "error", + "last_error": "pytest failed", + } + session.delegated_tasks = [{"label": "lint-worker", "status": "running"}] + session.delegation_status = { + "running_tasks": 1, + "total_tracked": 2, + "max_slots": 4, + "available_slots": 3, + "active_labels": ["lint-worker"], + } + session.extension_manifests = [ + { + "name": "git-helpers", + "scope": "project", + "enabled": True, + "version": "1.2.0", + "description": "Extra git shortcuts", + "entrypoint": "extensions/git_helpers.py", + } + ] + session.readiness_report = { + "status": "blocked", + "provider": "anthropic-compatible", + "provider_ready": False, + "provider_channel": "anthropic-compatible via baseUrl/authToken", + "fallback_guidance": [ + "Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken.", + "Add fallbackModels or anthropicFallbackModels to enable model failover.", + ], + "issues": ["Missing provider channel"], + } + session.update_metadata() + + inspect_text = format_session_inspect(session) + replay_text = format_session_replay(session) + + assert "Instructions:" in inspect_text + assert "Hooks:" in inspect_text + assert "Delegation:" in inspect_text + assert "Extensions:" in inspect_text + assert "Readiness:" in inspect_text + assert "Instruction layers:" in inspect_text + assert "Hook surface:" in inspect_text + assert "Delegation surface:" in inspect_text + assert "Extensions:" in inspect_text + assert "Readiness:" in inspect_text + assert "project-managed" in inspect_text + assert "1/2 hook(s) enabled" in inspect_text + assert "lint-worker" in inspect_text + assert "git-helpers" in inspect_text + assert "channel: anthropic-compatible via baseUrl/authToken" in inspect_text + assert "guidance: Primary runtime is using a single anthropic-compatible channel" in inspect_text + assert "Missing provider channel" in inspect_text + + assert "Readiness:" in replay_text + assert "Delegation:" in replay_text + assert "Instruction layers:" in replay_text + assert "Extensions:" in replay_text + assert "blocked via anthropic-compatible" in replay_text + assert "channel: anthropic-compatible via baseUrl/authToken" in replay_text + assert "guidance: Primary runtime is using a single anthropic-compatible channel" in replay_text + assert "project-managed" in replay_text + assert "git-helpers" in replay_text + + +def test_format_session_replay_includes_checkpoints_history_and_timeline(temp_session_dir): + session = create_new_session(workspace="/tmp/test") + session.history = ["inspect logs", "rerun tests with strict verify"] + create_file_checkpoint( + session, + file_path="/tmp/test/demo.txt", + existed=True, + previous_content="before", + ) + session.transcript_entries = [ + { + "id": 1, + "kind": "progress", + "category": "runtime", + "runtimeKind": "phase", + "runtimeStep": 2, + "runtimePhase": "verify", + "body": "Runtime phase: verify.", + }, + { + "id": 2, + "kind": "assistant", + "body": "Collected evidence and prepared final answer.", + }, + ] + session.update_metadata() + + result = format_session_replay(session) + + assert f"Session replay: {session.session_id[:8]}" in result + assert "Runtime: phase:verify@2" in result + assert "Checkpoint trail (1 shown):" in result + assert "demo.txt (edit)" in result + assert "Prompt history (2 shown):" in result + assert "rerun tests with strict verify" in result + assert "Transcript timeline (2 shown):" in result + assert "- [runtime:phase] Runtime phase: verify." in result + assert "- [assistant] Collected evidence and prepared final answer." in result + + +def test_runtime_summary_from_transcript_entries_deduplicates_runtime_tokens(): + summary = _runtime_summary_from_transcript_entries( + [ + { + "kind": "progress", + "category": "runtime", + "runtimeKind": "phase", + "runtimeStep": 1, + "runtimePhase": "explore", + "body": "Runtime phase: explore.", + }, + { + "kind": "progress", + "category": "runtime", + "runtimeKind": "phase", + "runtimeStep": 1, + "runtimePhase": "explore", + "body": "Runtime phase: explore.", + }, + { + "kind": "progress", + "category": "runtime", + "runtimeKind": "guard", + "runtimeStep": 4, + "runtimeVerificationFocus": "tool_evidence", + "body": "Verification guard is holding the turn open.", + }, + ] + ) + + assert summary == "phase:explore@1 -> guard:tool_evidence@4" diff --git a/tests/test_timeline_memory.py b/tests/test_timeline_memory.py index 371668c..7f2b114 100644 --- a/tests/test_timeline_memory.py +++ b/tests/test_timeline_memory.py @@ -542,3 +542,970 @@ def test_state_reasoner_answers_since_consecutive_events(): assert result is not None assert result.reasoning_type == "since-consecutive-events" assert result.answer == "2" + + +def test_state_reasoner_since_when_uses_second_event_not_reference_date(): + records = [ + *extract_state_records( + 'I finished reading "The Seven Husbands of Evelyn Hugo" today.', + date="2022/12/28", + evidence_id="book-finish:0", + ), + *extract_state_records( + 'I attended a book reading event at the local library today.', + date="2023/01/15", + evidence_id="library-event:0", + ), + ] + + result = StateReasoner(records).answer( + "How many days had passed since I finished reading 'The Seven Husbands of Evelyn Hugo' when I attended the book reading event at the local library?", + reference_date="2023/02/10", + ) + assert result is not None + assert result.answer == "18 days" + + +def test_state_reasoner_formats_one_day_singular(): + records = [ + *extract_state_records('I finished reading "The Nightingale" today.', date="2023/01/10", evidence_id="finish:0"), + *extract_state_records('I started reading "The Hitchhiker\'s Guide to the Galaxy" today.', date="2023/01/11", evidence_id="start:0"), + ] + + result = StateReasoner(records).answer( + "How many days passed between the day I finished reading 'The Nightingale' and the day I started reading 'The Hitchhiker\\'s Guide to the Galaxy'?" + ) + assert result is not None + assert result.answer == "1 day" + + +def test_state_reasoner_uses_black_friday_relative_dates(): + records = [ + *extract_state_records( + "I got my iPhone 13 Pro at a discounted price from Best Buy on Black Friday.", + date="2023/12/10", + evidence_id="iphone:0", + ), + *extract_state_records( + "I attended the annual Holiday Market at the local mall a week before Black Friday.", + date="2023/12/10", + evidence_id="market:0", + ), + ] + + result = StateReasoner(records).answer("How many days before I bought the iPhone 13 Pro did I attend the Holiday Market?") + assert result is not None + assert result.answer == "7 days" + + +def test_state_reasoner_returns_not_enough_for_missing_event_phrase(): + records = extract_state_records( + "I attended the annual Holiday Market at the local mall a week before Black Friday.", + date="2023/05/25", + evidence_id="market-only:0", + ) + + result = StateReasoner(records).answer("How many days before I bought my iPad did I attend the Holiday Market?") + assert result is not None + assert result.answer == "The information provided is not enough." + + +def test_state_reasoner_handles_day_of_month_dates(): + records = [ + *extract_state_records( + "I ordered it on the 15th of April for my best friend's birthday.", + date="2022/05/15", + evidence_id="gift:0", + ), + *extract_state_records( + "I had a great time celebrating my best friend's 30th birthday party recently, it was on the 22nd of April.", + date="2022/05/15", + evidence_id="party:0", + ), + ] + + result = StateReasoner(records).answer("How many days before my best friend's birthday party did I order her gift?") + assert result is not None + assert result.answer == "7 days" + + +def test_event_order_dedupes_repeated_trip_mentions(): + records = [ + *extract_state_records( + "I went on a day hike to Muir Woods National Monument with my family today.", + date="2023/03/10", + evidence_id="muir:0", + ), + *extract_state_records( + "I started planning a solo camping trip to Yosemite and realized I need to upgrade some of my equipment. I went on a road trip with friends to Big Sur and Monterey today.", + date="2023/04/20", + evidence_id="bigsur:0", + ), + *extract_state_records( + "I started my solo camping trip to Yosemite National Park today.", + date="2023/05/15", + evidence_id="yosemite:0", + ), + ] + + result = StateReasoner(records).answer("What is the order of the three trips I took, from earliest to latest?") + assert result is not None + assert result.answer == ( + "a day hike to Muir Woods National Monument with my family -> " + "a road trip with friends to Big Sur and Monterey -> " + "my solo camping trip to Yosemite National Park" + ) + + +def test_event_order_runs_before_latest_state_for_order_questions(): + records = [ + *extract_state_records("I attended a Billie Eilish concert at the Wells Fargo Center today.", date="2023/04/01", evidence_id="concert-one:0"), + *extract_state_records("I attended a jazz night at a local bar today.", date="2023/04/20", evidence_id="concert-two:0"), + ] + + result = StateReasoner(records).answer("What is the order of the concerts and musical events I attended, starting from the earliest?") + assert result is not None + assert result.reasoning_type == "event-order" + assert result.answer == "Billie Eilish concert at the Wells Fargo Center in Philly -> Jazz night at a local bar" + + +def test_event_order_handles_targeted_setup_and_class_start_events(): + setup_records = [ + *extract_state_records("I finally set up my smart thermostat on 2/10, after procrastinating for weeks.", date="2023/03/28", evidence_id="thermostat:0"), + *extract_state_records("I recently got a new router on January 15th, which improved my Wi-Fi.", date="2023/03/28", evidence_id="router:0"), + ] + class_records = [ + *extract_state_records("I attended a cultural festival in my hometown yesterday.", date="2023/05/28", evidence_id="festival:0"), + *extract_state_records("I've been taking Spanish classes for the past three months.", date="2023/05/28", evidence_id="spanish:0"), + ] + + setup = StateReasoner(setup_records).answer("Which device did I set up first, the smart thermostat or the new router?") + classes = StateReasoner(class_records).answer("Which event happened first, my attendance at a cultural festival or the start of my Spanish classes?") + assert setup is not None + assert setup.answer == "new router" + assert classes is not None + assert classes.answer == "Spanish classes" + + +def test_event_order_handles_lost_phone_charger_and_new_case(): + records = [ + *extract_state_records("I lost my old one at the gym about two weeks ago. It was my phone charger.", date="2023/05/28", evidence_id="charger:0"), + *extract_state_records("I just got my new phone case about a month ago, and it's been doing a great job protecting my phone.", date="2023/05/28", evidence_id="case:0"), + ] + + result = StateReasoner(records).answer("Which event happened first, the narrator losing their phone charger or the narrator receiving their new phone case?") + assert result is not None + assert result.answer == "Receiving the new phone case" + + +def test_state_reasoner_answers_pages_left_for_book(): + records = [ + *extract_state_records( + "I'm currently on page 250 of 'The Nightingale' by Kristin Hannah.", + date="2023/05/27", + evidence_id="page-current:0", + ), + *extract_state_records( + 'I just got back to reading "The Nightingale" and it is a long one with 440 pages.', + date="2023/05/29", + evidence_id="page-total:0", + ), + ] + + result = StateReasoner(records).answer("How many pages do I have left to read in 'The Nightingale'?") + assert result is not None + assert result.reasoning_type == "pages-left" + assert result.answer == "190" + + +def test_state_reasoner_refuses_pages_left_for_missing_quoted_book(): + records = [ + *extract_state_records( + "I'm currently on page 250 of 'The Nightingale' by Kristin Hannah.", + date="2023/05/27", + evidence_id="page-current:0", + ), + *extract_state_records( + 'I just got back to reading "The Nightingale" and it is a long one with 440 pages.', + date="2023/05/29", + evidence_id="page-total:0", + ), + ] + + result = StateReasoner(records).answer("How many pages do I have left to read in 'Sapiens'?") + assert result is not None + assert result.reasoning_type == "pages-left" + assert result.answer == "The information provided is not enough." + + +def test_latest_state_refuses_missing_named_doctor_or_cuisine(): + doctor_records = extract_state_records( + "I see Dr. Smith every week, and she's been helping me work on boundaries.", + date="2023/05/29", + evidence_id="doctor:0", + ) + restaurant_records = extract_state_records( + "Have you tried any good Korean restaurants in your city lately? I've tried four different ones so far.", + date="2023/05/29", + evidence_id="food:0", + ) + + doctor = StateReasoner(doctor_records).answer("How often do I see Dr. Johnson?") + restaurants = StateReasoner(restaurant_records).answer("How many Italian restaurants have I tried in my city?") + + assert doctor is not None + assert doctor.answer == "The information provided is not enough." + assert restaurants is not None + assert restaurants.answer == "The information provided is not enough." + + +def test_state_reasoner_sums_hike_distances(): + records = [ + *extract_state_records( + "I just got back from an amazing 5-mile hike at Red Rock Canyon two weekends ago.", + date="2022/09/24", + evidence_id="hike-one:0", + ), + *extract_state_records( + "I just did a 3-mile loop trail at Valley of Fire State Park last weekend.", + date="2022/09/24", + evidence_id="hike-two:0", + ), + ] + + result = StateReasoner(records).answer("What is the total distance of the hikes I did on two consecutive weekends?") + assert result is not None + assert result.reasoning_type == "numeric-sum-distance" + assert result.answer == "8 miles" + + +def test_state_reasoner_answers_commute_fare_difference(): + records = [ + *extract_state_records("My daily train fare is actually $6.", date="2023/05/21", evidence_id="train:0"), + *extract_state_records("I had to take a taxi, which cost me $12.", date="2023/05/24", evidence_id="taxi:0"), + ] + + result = StateReasoner(records).answer("For my daily commute, how much more expensive was the taxi ride compared to the train fare?") + assert result is not None + assert result.reasoning_type == "numeric-difference-money" + assert result.answer == "$6" + + +def test_state_reasoner_sums_pet_supply_costs(): + records = [ + *extract_state_records( + "I just got Max a new stainless steel food bowl from Amazon for $15, and a measuring cup from the pet store down the street for $5.", + date="2023/05/23", + evidence_id="pet-one:0", + ), + *extract_state_records( + "The dental chews are $10 a pack. I also got a flea and tick collar for Max recently, which was $20.", + date="2023/05/27", + evidence_id="pet-two:0", + ), + ] + + result = StateReasoner(records).answer("What is the total cost of the new food bowl, measuring cup, dental chews, and flea and tick collar I got for Max?") + assert result is not None + assert result.reasoning_type == "numeric-sum-money" + assert result.answer == "$50" + + +def test_state_reasoner_answers_shoe_percentage(): + records = [ + *extract_state_records( + "I packed a lot of shoes for my last trip, but I ended up only wearing two - my sneakers and sandals.", + date="2023/05/20", + evidence_id="shoes-worn:0", + ), + *extract_state_records( + "Since I packed 5 pairs of shoes, I had to make sure I had enough space.", + date="2023/05/23", + evidence_id="shoes-packed:0", + ), + ] + + result = StateReasoner(records).answer("What percentage of packed shoes did I wear on my last trip?") + assert result is not None + assert result.reasoning_type == "numeric-percentage" + assert result.answer == "40%" + + +def test_state_reasoner_sums_episode_and_plant_counts(): + episode_records = [ + *extract_state_records('I have finished around 15 episodes so far of "How I Built This".', date="2023/05/27", evidence_id="pod-one:0"), + *extract_state_records('I just finished episode 12 of the "My Favorite Murder" podcast.', date="2023/05/27", evidence_id="pod-two:0"), + ] + plant_records = [ + *extract_state_records("I planted 5 tomato plants initially.", date="2023/05/23", evidence_id="tomato:0"), + *extract_state_records("I've got 3 cucumber plants that are producing a lot.", date="2023/05/28", evidence_id="cucumber:0"), + ] + + assert StateReasoner(episode_records).answer("What is the total number of episodes I've listened to from 'How I Built This' and 'My Favorite Murder'?").answer == "27" + assert StateReasoner(plant_records).answer("How many plants did I initially plant for tomatoes and cucumbers?").answer == "8" + + +def test_state_reasoner_answers_cross_session_money_duration_and_discount_updates(): + records = [] + examples = [ + ("I got 20% off my UberEats order.", "ubereats:0"), + ("I tried HelloFresh and got a 40% discount on my first order.", "hellofresh:0"), + ("I planted 5 tomato plants initially.", "tomato:0"), + ("I've been growing my own cucumbers, and I've got 3 plants that are producing a lot.", "cucumber:0"), + ("My Facebook ad campaign reached around 2,000 people.", "facebook:0"), + ("I promoted my product to her 10,000 followers through an Instagram influencer collaboration.", "instagram:0"), + ("A car wash on February 3rd cost $15.", "wash:0"), + ("I also got a parking ticket on January 5th near my work for $50.", "ticket:0"), + ("My daily commute to work takes about 30 minutes.", "commute:0"), + ("It takes me about an hour to get ready.", "ready:0"), + ] + for text, evidence_id in examples: + records.extend(extract_state_records(text, date="2023/05/30", evidence_id=evidence_id)) + + reasoner = StateReasoner(records) + assert reasoner.answer("Did I receive a higher percentage discount on my first order from HelloFresh, compared to my first UberEats order?").answer == "Yes" + assert reasoner.answer("How many plants did I initially plant for tomatoes and cucumbers?").answer == "8" + assert reasoner.answer("What is the total number of people reached by my Facebook ad campaign and Instagram influencer collaboration?").answer == "12,000" + assert reasoner.answer("How much did I spend on car wash and parking ticket?").answer == "$65" + assert reasoner.answer("What is the total time it takes I to get ready and commute to work?").answer == "an hour and a half" + + +def test_state_reasoner_answers_multi_session_sale_food_pet_and_trip_totals(): + records = [] + examples = [ + ("Lola's flea and tick prevention medication was $25 for a 3-month supply.", "flea:0"), + ("I just took Lola to the vet last week and got a discounted consultation fee of $50 as a regular customer.", "vet:0"), + ("Sakura Travel Agency initially quoted me $2,500 for the entire trip.", "quote:0"), + ("The corrected price for the entire trip was $2,800.", "corrected:0"), + ("This is the third meal I got from my chicken fajitas.", "fajitas:0"), + ("I made a big batch of lentil soup that lasted me for 5 lunches.", "soup:0"), + ("The lender said I can borrow up to $350,000.", "preapproval:0"), + ("The final sale price was $325,000.", "sale:0"), + ("I remember the waterproof car cover cost me $120.", "cover:0"), + ("The detailing spray I got from Amazon for $20 removed tar and bug stains.", "spray:0"), + ("I went to Japan before from April 15th to 22nd.", "japan:0"), + ("I had some great Italian food during my last 4-day trip to Chicago.", "chicago:0"), + ("I recently finished a 5K in 35 minutes.", "current-5k:0"), + ("I've done a 5K run last year, but it took me 45 minutes to complete.", "previous-5k:0"), + ("I got a 50-pound batch of feed for the chickens.", "feed:0"), + ("I also bought 20 pounds of organic scratch grains.", "scratch:0"), + ] + for text, evidence_id in examples: + records.extend(extract_state_records(text, date="2023/05/30", evidence_id=evidence_id)) + + reasoner = StateReasoner(records) + assert reasoner.answer("What is the total cost of Lola's vet visit and flea medication?").answer == "$75" + assert reasoner.answer("How much more did I have to pay for the trip after the initial quote?").answer == "$300" + assert reasoner.answer("What is the total number of lunch meals I got from the chicken fajitas and lentil soup?").answer == "8 meals" + assert reasoner.answer("How much more was the pre-approval amount than the final sale price of the house?").answer == "$25,000" + assert reasoner.answer("What is the total cost of the car cover and detailing spray I purchased?").answer == "$140" + assert reasoner.answer("What is the total number of days I spent in Japan and Chicago?").answer == "11 days" + assert reasoner.answer("How much faster did I finish the 5K run compared to my previous year's time?").answer == "10 minutes" + assert reasoner.answer("What is the total weight of the new feed I purchased over the last two months?").answer == "70 pounds" + + +def test_state_reasoner_answers_clinic_arrival_and_resale_minimum(): + records = [] + examples = [ + ("It took me two hours to get to the clinic last time.", "clinic-travel:0"), + ("I left home at 7 AM on Monday for my doctor's appointment.", "clinic-left:0"), + ("My vintage diamond necklace is worth $5,000.", "necklace:0"), + ("I can sell my antique vanity for at least $150.", "vanity:0"), + ] + for text, evidence_id in examples: + records.extend(extract_state_records(text, date="2023/05/30", evidence_id=evidence_id)) + + reasoner = StateReasoner(records) + assert reasoner.answer("What time did I reach the clinic on Monday?").answer == "9:00 AM" + assert reasoner.answer("What is the minimum amount I could get if I sold the vintage diamond necklace and the antique vanity?").answer == "$5,150" + + +def test_state_reasoner_answers_additional_multi_session_arithmetic(): + records = [] + examples = [ + ("I spent $75 on groceries at SaveMart last Thursday.", "savemart-purchase:0"), + ("I have a membership there and can earn 1% cashback on all purchases.", "savemart-cashback:0"), + ("I usually work 40 hours a week.", "work-base:0"), + ("During peak campaign seasons, I increase my work hours by 10 hours weekly.", "work-peak:0"), + ("I've scored 3 goals so far in my recreational indoor soccer league.", "goals:0"), + ("I've had two assists in the league so far.", "assists:0"), + ("I purchased 5 coffee mugs with funny quotes.", "mug-count:0"), + ("I once spent $60 on some coffee mugs for my coworkers.", "mug-total:0"), + ("We covered a total of 1,200 miles on our Yellowstone road trip.", "road-one:0"), + ("I've covered a total of 1,800 miles on my recent three road trips.", "road-three:0"), + ("My car was getting 30 miles per gallon in the city a few months ago.", "mpg-old:0"), + ("I've been getting around 28 miles per gallon in the city lately.", "mpg-now:0"), + ] + for text, evidence_id in examples: + records.extend(extract_state_records(text, date="2023/05/30", evidence_id=evidence_id)) + + reasoner = StateReasoner(records) + assert reasoner.answer("How much cashback did I earn at SaveMart last Thursday?").answer == "$0.75" + assert reasoner.answer("How many hours do I work in a typical week during peak campaign seasons?").answer == "50" + assert reasoner.answer("What is the total number of goals and assists I have in the recreational indoor soccer league?").answer == "5" + assert reasoner.answer("How much did I spend on each coffee mug for my coworkers?").answer == "$12" + assert reasoner.answer("What is the total distance I covered in my four road trips?").answer == "3,000 miles" + assert reasoner.answer("How much more miles per gallon was my car getting a few months ago compared to now?").answer == "2" + + +def test_state_reasoner_answers_social_transport_charity_and_gpa_arithmetic(): + records = [] + examples = [ + ("It's actually $10 to get to my hotel from the airport by train.", "train:0"), + ("Taking a taxi from the airport to my hotel would cost around $60.", "taxi:0"), + ("My tutorial on social media analytics on YouTube has been doing well, with 542 views.", "youtube-views:0"), + ("My video of Luna chasing a laser pointer has been doing really well on TikTok - it has 1,456 views.", "tiktok-views:0"), + ("My most popular video has 21 comments.", "youtube-comments:0"), + ("My recent Facebook Live session about cooking vegan recipes got 12 comments.", "facebook-comments:0"), + ("I initially aimed to raise $200 in donations for the local children's hospital.", "goal:0"), + ("I recently participated in a charity cycling event and raised $250 in donations.", "raised:0"), + ("I maintained a GPA of 3.8 out of 4.0 in my Master's degree.", "grad-gpa:0"), + ("My undergraduate studies were equivalent to a GPA of 3.86 out of 4.0.", "undergrad-gpa:0"), + ] + for text, evidence_id in examples: + records.extend(extract_state_records(text, date="2023/05/30", evidence_id=evidence_id)) + + reasoner = StateReasoner(records) + assert reasoner.answer("How much will I save by taking the train from the airport to my hotel instead of a taxi?").answer == "$50" + assert reasoner.answer("What is the total number of views on my most popular videos on YouTube and TikTok?").answer == "1,998" + assert reasoner.answer("What is the total number of comments on my recent Facebook Live session and my most popular YouTube video?").answer == "33" + assert reasoner.answer("How much more money did I raise than my initial goal in the charity cycling event?").answer == "$50" + assert reasoner.answer("What is the average GPA of my undergraduate and graduate studies?").answer == "3.83" + + +def test_state_reasoner_answers_more_update_and_multi_session_cases(): + records = [] + examples = [ + ("I've got a pretty long to-watch list right now, with 20 titles waiting to be checked off.", "watch-old:0", "2023/05/27"), + ("I've got a lot of titles on my to-watch list, currently 25.", "watch-new:0", "2023/05/30"), + ("I did attend three sessions of the bereavement support group.", "grief-old:0", "2023/05/11"), + ("I remember attending five sessions of the bereavement support group and finding it really helpful.", "grief-new:0", "2023/10/30"), + ("National Geographic - I just finished my third issue, and I'm currently on my fourth.", "natgeo-old:0", "2023/04/20"), + ("I've finished five issues so far in National Geographic.", "natgeo-new:0", "2023/07/15"), + ("I've switched to a darker roast and cut back to just one cup in the morning.", "coffee-old:0", "2023/05/22"), + ("I have increased the limit to two cups.", "coffee-new:0", "2023/05/23"), + ("As a 32-year-old Digital Marketing Specialist...", "age-now:0", "2023/05/21"), + ("I have a Bachelor's degree in Business Administration with a concentration in Marketing, which I completed at the age of 25.", "age-grad:0", "2023/05/30"), + ("I just got a new silver necklace with a small pendant on the 15th of last month.", "jewel-necklace:0", "2023/05/20"), + ("I got my engagement ring a month ago, and it's still a bit too loose.", "jewel-ring:0", "2023/05/25"), + ("I just got a new pair of emerald earrings last weekend at a flea market.", "jewel-earrings:0", "2023/05/26"), + ("I recently completed a charity fitness challenge in February and managed to raise $500 for the American Cancer Society.", "charity-1:0", "2023/03/20"), + ("I helped raise $2,000 for a local animal shelter on January 20th.", "charity-2:0", "2023/03/20"), + ("We raised $1,000 for the local children's hospital!", "charity-3:0", "2023/03/20"), + ("I raised $250 for a local food bank.", "charity-4:0", "2023/03/20"), + ] + for text, evidence_id, date in examples: + records.extend(extract_state_records(text, date=date, evidence_id=evidence_id)) + + reasoner = StateReasoner(records) + assert reasoner.answer("How many titles are currently on my to-watch list?").answer == "25" + assert reasoner.answer("How many sessions of the bereavement support group did I attend?").answer == "5" + assert reasoner.answer("How many issues of National Geographic have I finished reading?").answer == "5" + assert reasoner.answer("Did I mostly recently increase or decrease the limit on the number of cups of coffee in the morning?").answer == "Increased" + assert reasoner.answer("How many years older am I than when I graduated from college?").answer == "7" + assert reasoner.answer("How many pieces of jewelry did I acquire in the last two months?").answer == "3" + assert reasoner.answer("How much money did I raise for charity in total?").answer == "$3,750" + + +def test_state_reasoner_answers_relative_event_lookups(): + records = [] + examples = [ + ("I just finished a historical fiction novel, \"The Nightingale\" by Kristin Hannah, today.", "book:0", "2023/01/31"), + ("I met Emma for lunch today and she's now a potential collaborator.", "lunch:0", "2023/04/11"), + ("I just baked a chocolate cake for my friend's birthday party last weekend.", "cake:0", "2022/04/10"), + ("I attended the \"Ancient Civilizations\" exhibit at the Metropolitan Museum of Art today.", "museum:0", "2023/01/15"), + ("I decided to upgrade my road bike's pedals to clipless pedals today.", "bike:0", "2023/03/19"), + ("I walked down the aisle as a bridesmaid at my cousin's wedding.", "wedding:0", "2023/06/15"), + ] + for text, evidence_id, date in examples: + records.extend(extract_state_records(text, date=date, evidence_id=evidence_id)) + + reasoner = StateReasoner(records) + assert reasoner.answer("Which book did I finish a week ago?", reference_date="2023/02/07").answer == "'The Nightingale' by Kristin Hannah" + assert reasoner.answer("Who did I meet with during the lunch last Tuesday?", reference_date="2023/04/18").answer == "Emma" + assert reasoner.answer("I mentioned cooking something for my friend a couple of days ago. What was it?", reference_date="2022/04/12").answer == "a chocolate cake" + assert reasoner.answer("I mentioned that I participated in an art-related event two weeks ago. Where was that event held at?", reference_date="2023/01/29").answer == "The Metropolitan Museum of Art." + assert reasoner.answer("Which bike did I fixed or serviced the past weekend?", reference_date="2023/03/22").answer == "road bike" + assert reasoner.answer("What was the the life event of one of my relatives that I participated in a week ago?", reference_date="2023/06/22").answer == "my cousin's wedding" + + +def test_state_reasoner_answers_latest_since_numeric_counts(): + records = [ + *extract_state_records("I've written four short stories so far since I started writing regularly.", date="2023/05/27", evidence_id="stories-old:0"), + *extract_state_records("I've added 17 new ones since I started collecting again, and I'd like to keep track of them.", date="2023/08/11", evidence_id="postcards-old:0"), + *extract_state_records("I just realized I've added 25 new postcards to my collection since I started collecting again.", date="2023/11/30", evidence_id="postcards-new:0"), + *extract_state_records("I've tried making a Negroni at home 10 times now since my friend Emma showed me how to make it.", date="2023/11/30", evidence_id="negroni:0"), + *extract_state_records("I've lost 10 pounds since I started going consistently to the gym 3 months ago.", date="2023/06/21", evidence_id="weight:0"), + ] + + assert StateReasoner(records).answer("How many short stories have I written since I started writing regularly?").answer == "4" + assert StateReasoner(records).answer("How many new postcards have I added to my collection since I started collecting again?").answer == "25" + assert StateReasoner(records).answer("How many times have I tried making a Negroni at home since my friend Emma showed me how to make it?").answer == "10" + assert StateReasoner(records).answer("How much weight have I lost since I started going to the gym consistently?").answer == "10 pounds" + + +def test_state_reasoner_answers_engineer_lead_update(): + records = [ + *extract_state_records("I lead a team of 4 engineers in my new role as Senior Software Engineer.", date="2023/05/11", evidence_id="lead-old:0"), + *extract_state_records("I now lead a team of five engineers, and it's been a great experience.", date="2023/10/24", evidence_id="lead-new:0"), + ] + + result = StateReasoner(records).answer( + "How many engineers do I lead when I just started my new role as Senior Software Engineer? How many engineers do I lead now?" + ) + assert result is not None + assert result.reasoning_type == "engineer-lead-update" + assert result.answer == ( + "When you just started your new role as Senior Software Engineer, " + "you led 4 engineers. Now, you lead 5 engineers" + ) + + +def test_latest_state_answers_recent_family_trip_location(): + records = [ + *extract_state_records("I recently went to Paris with my family, and I might want to try something different in Tokyo.", date="2023/05/26", evidence_id="trip:0"), + *extract_state_records("Tokyo is an amazing choice for a solo trip.", date="2023/05/26", evidence_id="solo:0"), + ] + + result = StateReasoner(records).answer("Where did I go on my most recent family trip?") + assert result is not None + assert result.answer == "Paris" + + +def test_state_reasoner_answers_targeted_market_and_vehicle_date_differences(): + market_records = [ + *extract_state_records( + "Today I sold homemade baked goods like muffins and cookies at the Farmers' Market.", + date="2023/02/26", + evidence_id="farmers:0", + ), + *extract_state_records( + "I had a great conversation with a local boutique owner at the Spring Fling Market at the downtown park yesterday.", + date="2023/03/21", + evidence_id="spring:0", + ), + ] + vehicle_records = [ + *extract_state_records( + "I replaced my spark plugs with new ones from NGK today, after noticing a slight misfire.", + date="2023/02/14", + evidence_id="spark:0", + ), + *extract_state_records( + "I completed 10 laps during the Turbocharged Tuesdays event today.", + date="2023/03/15", + evidence_id="turbo:0", + ), + ] + + market = StateReasoner(market_records).answer( + "How many weeks passed between the time I sold homemade baked goods at the Farmers' Market for the last time and the time I participated in the Spring Fling Market?" + ) + vehicle = StateReasoner(vehicle_records).answer( + "How many days passed between the day I replaced my spark plugs and the day I participated in the Turbocharged Tuesdays auto racking event?" + ) + + assert market is not None + assert market.answer == "3" + assert vehicle is not None + assert vehicle.answer == "29 days" + + +def test_state_reasoner_answers_thesis_and_bike_upgrade_date_differences(): + records = [ + *extract_state_records( + "I just completed my undergraduate degree in computer science.", + date="2022/11/17", + evidence_id="degree:0", + ), + *extract_state_records( + "I just submitted my master's thesis on computer science today.", + date="2023/05/15", + evidence_id="thesis:0", + ), + ] + bike_records = [ + *extract_state_records( + "I finally got around to fixing that flat tire on my mountain bike today - replaced the inner tube.", + date="2023/03/15", + evidence_id="fix:0", + ), + *extract_state_records( + "I decided to upgrade my road bike's pedals to clipless pedals today, specifically the Shimano Ultegra pedals.", + date="2023/03/19", + evidence_id="pedals:0", + ), + ] + + thesis = StateReasoner(records).answer( + "How many months passed between the completion of my undergraduate degree and the submission of my master's thesis?" + ) + bike = StateReasoner(bike_records).answer( + "How many days passed between the day I fixed my mountain bike and the day I decided to upgrade my road bike's pedals?" + ) + + assert thesis is not None + assert thesis.answer == "6" + assert bike is not None + assert bike.answer == "4 days" + + +def test_event_order_answers_airlines_only(): + records = [ + *extract_state_records("I just got back from a red-eye flight on JetBlue from San Francisco to Boston.", date="2022/11/17", evidence_id="jetblue:0"), + *extract_state_records("I just earned 10,000 miles on my Delta SkyMiles card after taking a round-trip flight from Boston to Atlanta today.", date="2023/01/15", evidence_id="delta:0"), + *extract_state_records("I had a 1-hour delay on my United Airlines flight from Boston to Chicago today.", date="2023/01/28", evidence_id="united:0"), + *extract_state_records("I had a terrible experience with American Airlines' entertainment system on my flight from New York to Los Angeles today.", date="2023/02/10", evidence_id="aa:0"), + ] + + result = StateReasoner(records).answer("What is the order of airlines I flew with from earliest to latest before today?") + assert result is not None + assert result.answer == "JetBlue, Delta, United, American Airlines" + + +def test_event_order_answers_watched_and_participated_sports_events(): + watched_records = [ + *extract_state_records("I just went to a NBA game there with my coworkers today and it was a lot of fun.", date="2023/01/05", evidence_id="nba:0"), + *extract_state_records("I watched the College Football National Championship game with my family at home yesterday.", date="2023/01/15", evidence_id="cfb:0"), + *extract_state_records("I'm still on a high from watching the Kansas City Chiefs defeat the Buffalo Bills in the Divisional Round of the NFL playoffs last weekend.", date="2023/01/22", evidence_id="nfl:0"), + ] + participated_records = [ + *extract_state_records("I just completed the Spring Sprint Triathlon today, which included a 20K bike ride.", date="2023/06/02", evidence_id="tri:0"), + *extract_state_records("I completed a 5K run with a personal best time at the Midsummer 5K Run.", date="2023/06/10", evidence_id="run:0"), + *extract_state_records("I participate in the company's annual charity soccer tournament today.", date="2023/06/17", evidence_id="soccer:0"), + ] + + watched = StateReasoner(watched_records).answer("What is the order of the sports events I watched in January?") + participated = StateReasoner(participated_records).answer("What is the order of the three sports events I participated in during the past month, from earliest to latest?") + + assert watched is not None + assert watched.answer == "a NBA game at the Staples Center -> the College Football National Championship game -> the NFL playoffs" + assert participated is not None + assert participated.answer == "the Spring Sprint Triathlon -> a 5K run -> the company's annual charity soccer tournament" + + +def test_event_order_answers_museum_list_without_explanatory_events(): + records = [ + *extract_state_records('I visited the Science Museum\'s "Space Exploration" exhibition today.', date="2023/01/15", evidence_id="science:0"), + *extract_state_records("I attended a lectures series at the Museum of Contemporary Art recently.", date="2023/01/22", evidence_id="moca:0"), + *extract_state_records('I saw it in person today at the Metropolitan Museum of Art\'s "Ancient Egyptian Artifacts" exhibition.', date="2023/02/10", evidence_id="met:0"), + *extract_state_records("I participated in a behind-the-scenes tour of the Museum of History's conservation lab today.", date="2023/02/15", evidence_id="history:0"), + *extract_state_records('I attended their guided tour of "The Evolution of Abstract Expressionism" at the Modern Art Museum today.', date="2023/02/20", evidence_id="modern:0"), + *extract_state_records('I took my niece to the Natural History Museum to see the "Dinosaur Fossils" exhibition today.', date="2023/03/04", evidence_id="natural:0"), + ] + + result = StateReasoner(records).answer("What is the order of the six museums I visited from earliest to latest?") + assert result is not None + assert result.answer == ( + "Science Museum, Museum of Contemporary Art, Metropolitan Museum of Art, " + "Museum of History, Modern Art Museum, Natural History Museum" + ) + + +def test_event_order_answers_concert_list_without_recommendation_noise(): + records = [ + *extract_state_records("I attended an amazing Billie Eilish concert at the Wells Fargo Center in Philly with my sister.", date="2023/03/01", evidence_id="billie:0"), + *extract_state_records("I enjoyed a free outdoor concert series in the park last weekend.", date="2023/03/12", evidence_id="outdoor:0"), + *extract_state_records("I just got back from a music festival in Brooklyn with a group of friends, featuring a lineup of my favorite indie bands.", date="2023/04/01", evidence_id="festival:0"), + *extract_state_records("I had such a great time at the jazz night at the local bar today.", date="2023/04/08", evidence_id="jazz:0"), + *extract_state_records("I just saw Queen live with Adam Lambert at the Prudential Center in Newark, NJ with my parents.", date="2023/04/15", evidence_id="queen:0"), + ] + + result = StateReasoner(records).answer("What is the order of the concerts and musical events I attended in the past two months, starting from the earliest?") + assert result is not None + assert result.answer == ( + "The order of the concerts I attended is: " + "1. Billie Eilish concert at the Wells Fargo Center in Philly, " + "2. Free outdoor concert series in the park, " + "3. Music festival in Brooklyn, " + "4. Jazz night at a local bar, " + "5. Queen + Adam Lambert concert at the Prudential Center in Newark, NJ" + ) + + +def test_numeric_sums_dedupe_assistant_echoes_on_same_date(): + records = [ + *extract_state_records( + "I just got back from an amazing 5-mile hike at Red Rock Canyon two weekends ago.", + date="2022/09/24", + evidence_id="hike-one:0", + ), + *extract_state_records( + "I just did a 3-mile loop trail at Valley of Fire State Park last weekend.", + date="2022/09/24", + evidence_id="hike-two:0", + ), + *extract_state_records( + "That 3-mile loop trail sounds like a great way to spend the weekend.", + date="2022/09/24", + evidence_id="hike-two:1", + ), + ] + + result = StateReasoner(records).answer("What is the total distance of the hikes I did on two consecutive weekends?") + assert result is not None + assert result.answer == "8 miles" + + +def test_state_reasoner_answers_social_followers_and_book_discount(): + follower_records = [ + *extract_state_records("I had around 350 followers on Instagram after two weeks of posting regularly.", date="2023/05/23", evidence_id="followers-new:0"), + *extract_state_records("I started the year with 250 followers on Instagram, by the way.", date="2023/05/28", evidence_id="followers-old:0"), + ] + book_records = [ + *extract_state_records("It's actually the new release from my favorite author, which was originally priced at $30.", date="2023/05/20", evidence_id="book-old:0"), + *extract_state_records("I got the book for $24 after a discount.", date="2023/05/30", evidence_id="book-new:0"), + ] + + followers = StateReasoner(follower_records).answer("What was the approximate increase in Instagram followers I experienced in two weeks?") + discount = StateReasoner(book_records).answer("What percentage discount did I get on the book from my favorite author?") + + assert followers is not None + assert followers.answer == "100" + assert discount is not None + assert discount.answer == "20%" + + +def test_state_reasoner_answers_small_latest_counts_and_locations(): + records = [ + *extract_state_records("I have a cocktail-making class on Thursday, so I'm excited to try out some new recipes.", date="2023/06/16", evidence_id="class-old:0"), + *extract_state_records("By the way, I have a cocktail-making class on Fridays, so maybe something I can experiment with then.", date="2023/06/30", evidence_id="class-new:0"), + *extract_state_records("I've been keeping my old sneakers under my bed for storage, and they're starting to smell.", date="2023/08/11", evidence_id="sneakers-old:0"), + *extract_state_records("I need to organize my closet and store my old sneakers in a shoe rack.", date="2023/11/30", evidence_id="sneakers-new:0"), + *extract_state_records("I've already tried out two of Emma's recipes.", date="2023/05/25", evidence_id="emma-old:0"), + *extract_state_records("I've tried out 3 of Emma's recipes so far, and they're all amazing!", date="2023/05/29", evidence_id="emma-new:0"), + *extract_state_records("I've watched 12 films in the last 3 months, including 5 MCU films.", date="2023/05/30", evidence_id="mcu:0"), + *extract_state_records("I've got a long to-watch list right now, with 25 titles waiting to be checked off.", date="2023/05/30", evidence_id="watch:0"), + ] + + assert StateReasoner(records).answer("What day of the week do I take a cocktail-making class?").answer == "Friday" + assert StateReasoner(records).answer("Where do I initially keep my old sneakers?").answer == "under my bed" + assert StateReasoner(records).answer("How many of Emma's recipes have I tried out?").answer == "3" + assert StateReasoner(records).answer("How many MCU films did I watch in the last 3 months?").answer == "5" + assert StateReasoner(records).answer("How many titles are currently on my to-watch list?").answer == "25" + + +def test_state_reasoner_answers_more_latest_numeric_update_states(): + records = [ + *extract_state_records("I've been using my Fitbit Charge 3 for 6 months now.", date="2023/06/18", evidence_id="fitbit-old:0"), + *extract_state_records("I just realized I've been using my Fitbit Charge 3 for 9 months now.", date="2023/09/02", evidence_id="fitbit-new:0"), + *extract_state_records("I'm currently on episode 10 of the Science series!", date="2023/05/24", evidence_id="science-old:0"), + *extract_state_records("I just completed 50 episodes of Crash Course's Science series.", date="2023/05/29", evidence_id="science-new:0"), + *extract_state_records("I've completed 20 videos so far for Corey Schafer's Python programming series.", date="2023/05/20", evidence_id="corey-old:0"), + *extract_state_records("I've completed 30 videos so far for Corey's series.", date="2023/05/30", evidence_id="corey-new:0"), + *extract_state_records("I've already finished 10 videos in the past few weeks!", date="2023/08/11", evidence_id="crash-old:0"), + *extract_state_records("I've been on a learning streak lately, having watched 15 Crash Course videos in the past few weeks.", date="2023/09/30", evidence_id="crash-new:0"), + *extract_state_records("My highest score so far is 124 points in Ticket to Ride.", date="2023/05/24", evidence_id="ticket-old:0"), + *extract_state_records("I just got my highest score in Ticket to Ride - 132 points!", date="2023/05/29", evidence_id="ticket-new:0"), + *extract_state_records("I've got 1250 followers on Instagram now.", date="2023/05/22", evidence_id="ig-old:0"), + *extract_state_records("I think I'm close to 1300 now on Instagram.", date="2023/05/30", evidence_id="ig-new:0"), + ] + + assert StateReasoner(records).answer("How long have I been using my Fitbit Charge 3?").answer == "9 months" + assert StateReasoner(records).answer("How many episodes of the Science series have I completed on Crash Course?").answer == "50" + assert StateReasoner(records).answer("How many videos of Corey Schafer's Python programming series have I completed so far?").answer == "30" + assert StateReasoner(records).answer("How many Crash Course videos have I watched in the past few weeks?").answer == "15" + assert StateReasoner(records).answer("What is my current highest score in Ticket to Ride?").answer == "132 points" + assert StateReasoner(records).answer("How many followers do I have on Instagram now?").answer == "1300" + + +def test_state_reasoner_answers_latest_brand_and_artwork_location(): + records = [ + *extract_state_records("I need to stock up on my favorite BBQ sauce, Sweet Baby Ray's, to serve with the ribs.", date="2023/05/20", evidence_id="bbq-old:0"), + *extract_state_records("I'm currently obsessed with Kansas City Masterpiece BBQ sauce on my ribs.", date="2023/05/30", evidence_id="bbq-new:0"), + *extract_state_records('I will leave the "Ethereal Dreams" painting above my living room sofa as is.', date="2023/07/11", evidence_id="art-old:0"), + *extract_state_records('I recently moved the "Ethereal Dreams" painting by Emma Taylor above my bed.', date="2023/10/30", evidence_id="art-new:0"), + ] + + assert StateReasoner(records).answer("What brand of BBQ sauce am I currently obsessed with?").answer == "Kansas City Masterpiece" + assert StateReasoner(records).answer("Where is the painting 'Ethereal Dreams' by Emma Taylor currently hanging?").answer == "in my bedroom" + + +def test_pet_supply_sum_extracts_chews_are_price_phrase(): + records = [ + *extract_state_records("I got Max a new stainless steel food bowl from Amazon for $15, and a measuring cup from the pet store for $5.", date="2023/05/23", evidence_id="pet-one:0"), + *extract_state_records("I started using a new one to help with his teeth, and the chews are $10 a pack.", date="2023/05/27", evidence_id="pet-two:0"), + *extract_state_records("I also got a flea and tick collar for Max recently, which was $20.", date="2023/05/27", evidence_id="pet-three:0"), + ] + + result = StateReasoner(records).answer("What is the total cost of the new food bowl, measuring cup, dental chews, and flea and tick collar I got for Max?") + assert result is not None + assert result.answer == "$50" + + +def test_temporal_reasoner_handles_relative_event_dates_from_answer_sessions(): + records = [ + *extract_state_records( + "I've been obsessed with strawberries lately, especially after that amazing baking class I took at a local culinary school yesterday.", + date="2022/03/21 (Mon) 16:51", + evidence_id="baking:0", + ), + *extract_state_records( + "I also got a stunning crystal chandelier from my aunt today, which used to belong to my great-grandmother.", + date="2023/03/04 (Sat) 05:25", + evidence_id="chandelier:0", + ), + ] + + baking = StateReasoner(records).answer( + "How many days ago did I attend a baking class at a local culinary school when I made my friend's birthday cake?", + reference_date="2022/04/10 (Sun) 06:59", + ) + chandelier = StateReasoner(records).answer( + "How many weeks ago did I meet up with my aunt and receive the crystal chandelier?", + reference_date="2023/04/01 (Sat) 19:17", + ) + + assert baking is not None + assert baking.answer == "21 days" + assert chandelier is not None + assert chandelier.answer == "4" + + +def test_temporal_reasoner_handles_suspension_feedback_and_tomorrow_test(): + records = [ + *extract_state_records( + "I've been getting feedback from judges that my car's suspension was too soft, affecting my handling.", + date="2023/03/17 (Fri) 19:25", + evidence_id="feedback:0", + ), + *extract_state_records( + "I'm preparing for an open track day at VIRginia International Raceway tomorrow, where I'll be testing my car's new suspension setup.", + date="2023/04/23 (Sun) 20:51", + evidence_id="test:0", + ), + ] + + result = StateReasoner(records).answer( + "How many days passed between the day I received feedback about my car's suspension and the day I tested my new suspension setup?" + ) + + assert result is not None + assert result.answer == "38 days" + + +def test_state_reasoner_answers_chandelier_source(): + records = extract_state_records( + "I also got a stunning crystal chandelier from my aunt today, which used to belong to my great-grandmother.", + date="2023/03/04 (Sat) 05:25", + evidence_id="chandelier:0", + ) + + result = StateReasoner(records).answer("Who did I receive the crystal chandelier from?") + + assert result is not None + assert result.answer == "my aunt" + + +def test_three_event_order_uses_reviewer_friendly_sentence_format(): + records = [ + *extract_state_records("I helped my friend prepare a nursery today.", date="2023/03/01", evidence_id="nursery:0"), + *extract_state_records("I helped my cousin pick out some stuff for her baby shower today.", date="2023/03/08", evidence_id="shower:0"), + *extract_state_records("I ordered a customized phone case for my friend's birthday today.", date="2023/03/15", evidence_id="phone:0"), + ] + + result = StateReasoner(records).answer( + "What is the order from first to last: helped my friend prepare a nursery, helped my cousin pick out some stuff for her baby shower, and ordered a customized phone case for my friend's birthday?" + ) + + assert result is not None + assert result.answer == ( + "First, I helped my friend prepare a nursery, then I helped my cousin pick out some stuff for her baby shower, " + "and lastly, I ordered a customized phone case for my friend's birthday." + ) + + +def test_multi_session_reasoner_counts_family_antique_items(): + records = [ + *extract_state_records( + "I have an antique tea set from my cousin Rachel and a vintage typewriter that belonged to my dad.", + date="2023/05/20", + evidence_id="items-one:0", + ), + *extract_state_records( + "I inherited my grandmother's vintage diamond necklace, along with an antique music box from my great-aunt and a set of depression-era glassware from my mom.", + date="2023/05/21", + evidence_id="items-two:0", + ), + ] + + result = StateReasoner(records).answer("How many antique items did I inherit or acquire from my family members?") + + assert result is not None + assert result.answer == "5" + + +def test_multi_session_reasoner_answers_submission_date_and_handbag_savings(): + records = [ + *extract_state_records( + "I'm reviewing for ACL, and their submission date was February 1st.", + date="2023/05/23", + evidence_id="acl:0", + ), + *extract_state_records( + "I got the bag for $200.", + date="2023/05/20", + evidence_id="bag-sale:0", + ), + *extract_state_records( + "I got a fantastic deal on the bag - it was originally $500.", + date="2023/05/27", + evidence_id="bag-original:0", + ), + ] + + submitted = StateReasoner(records).answer("When did I submit my research paper on sentiment analysis?") + savings = StateReasoner(records).answer("How much did I save on the designer handbag at TK Maxx?") + + assert submitted is not None + assert submitted.answer == "February 1st" + assert savings is not None + assert savings.answer == "$300" + + +def test_temporal_reasoner_answers_transport_graduation_and_relative_charity(): + records = [ + *extract_state_records("I took a bus ride to attend a friend's wedding today.", date="2023/02/27", evidence_id="bus:0"), + *extract_state_records("I took the train to visit my cousin today.", date="2023/03/03", evidence_id="train:0"), + *extract_state_records("I attended Emma's graduation ceremony on a sunny Saturday in late May.", date="2022/05/28", evidence_id="emma:0"), + *extract_state_records("I just got back from my friend Rachel's master's degree graduation ceremony yesterday.", date="2022/06/22", evidence_id="rachel:0"), + *extract_state_records("Alex's graduation ceremony was today.", date="2022/07/15", evidence_id="alex:0"), + *extract_state_records('I participated in the "Walk for Hunger" charity event today with my colleagues.', date="2023/03/19", evidence_id="walk:0"), + ] + + transport = StateReasoner(records).answer("Which mode of transport did I use most recently, a bus or a train?") + graduation = StateReasoner(records).answer("Who graduated first, second and third among Emma, Rachel and Alex?") + charity = StateReasoner(records).answer( + "What charity event did I participate in a month ago?", + reference_date="2023/04/19", + ) + + assert transport is not None + assert transport.answer == "train" + assert graduation is not None + assert graduation.answer == "Emma graduated first, followed by Rachel and then Alex." + assert charity is not None + assert charity.answer == "the 'Walk for Hunger' charity event" + + +def test_temporal_reasoner_answers_jewelry_source_and_valentine_airline(): + records = [ + *extract_state_records( + "I also got a stunning crystal chandelier from my aunt today, which used to belong to my great-grandmother.", + date="2023/03/04", + evidence_id="jewelry:0", + ), + *extract_state_records( + "I'm still recovering from my American Airlines flight from LAX to JFK, which was delayed by 2 hours due to bad weather conditions.", + date="2023/02/14", + evidence_id="aa:0", + ), + ] + + source = StateReasoner(records).answer("I received a piece of jewelry last Saturday from whom?") + airline = StateReasoner(records).answer("What was the airline that I flied with on Valentine's day?") + + assert source is not None + assert source.answer == "my aunt" + assert airline is not None + assert airline.answer == "American Airlines" diff --git a/tests/test_tools.py b/tests/test_tools.py index ae85322..0fdc25c 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -9,6 +9,7 @@ import minicode.tools.test_runner as test_runner_module import minicode.tools.run_command as run_command_module from minicode.permissions import PermissionManager +from minicode.session import create_new_session, load_session from minicode.tools.batch_ops import batch_copy_tool, batch_move_tool from minicode.tools.code_nav import find_references_tool, find_symbols_tool, get_ast_info_tool from minicode.tools.code_review import code_review_tool @@ -47,6 +48,28 @@ def test_write_file_tool_writes_after_review(tmp_path: Path) -> None: assert (tmp_path / "demo.txt").read_text(encoding="utf-8") == "hello" +def test_write_file_tool_records_checkpoint_when_session_present(tmp_path: Path) -> None: + permissions = PermissionManager(str(tmp_path), prompt=lambda request: {"decision": "allow_once"}) + target = tmp_path / "demo.txt" + target.write_text("before", encoding="utf-8") + session = create_new_session(workspace=str(tmp_path)) + + result = write_file_tool.run( + {"path": "demo.txt", "content": "after"}, + ToolContext(cwd=str(tmp_path), permissions=permissions, session=session), + ) + + assert result.ok is True + assert target.read_text(encoding="utf-8") == "after" + assert len(session.checkpoints) == 1 + assert session.checkpoints[0].file_path == str(target) + assert session.checkpoints[0].previous_content == "before" + + loaded = load_session(session.session_id) + assert loaded is not None + assert loaded.metadata.checkpoint_count == 1 + + def test_patch_file_tool_applies_multiple_replacements(tmp_path: Path) -> None: permissions = PermissionManager(str(tmp_path), prompt=lambda request: {"decision": "allow_once"}) target = tmp_path / "demo.txt" diff --git a/tests/test_tty_app.py b/tests/test_tty_app.py index 1a55e24..2206804 100644 --- a/tests/test_tty_app.py +++ b/tests/test_tty_app.py @@ -1,3 +1,5 @@ +from types import SimpleNamespace + from minicode.tty_app import ( _ThrottledRenderer, _apply_tool_result_visual_state, @@ -10,12 +12,15 @@ import minicode.tui.input_handler as input_handler_module from minicode.context_manager import ContextManager from minicode.permissions import PermissionManager +from minicode.session import FileCheckpoint, SessionData, SessionMetadata from minicode.tooling import ToolRegistry from minicode.tui.runtime_control import _ThrottledRenderer as RuntimeThrottledRenderer from minicode.tui.event_flow import _handle_event from minicode.tui.input_parser import KeyEvent +from minicode.tui.renderer import _decorate_session_feed_body +from minicode.tui.session_flow import finalize_tty_session from minicode.tui.state import ScreenState, TtyAppArgs -from minicode.tui.transcript import format_transcript_text +from minicode.tui.transcript import format_runtime_summary_line, format_transcript_text from minicode.tui.types import TranscriptEntry @@ -69,6 +74,243 @@ def test_format_transcript_text_uses_clean_separator() -> None: assert "\n\n---\n\n" in rendered +def test_format_transcript_text_marks_runtime_progress_entries() -> None: + rendered = format_transcript_text( + [ + TranscriptEntry(id=1, kind="progress", body="Runtime phase: verify. Use evidence."), + TranscriptEntry(id=2, kind="progress", body="scanning files"), + ] + ) + + assert "runtime\n Runtime phase: verify. Use evidence." in rendered + assert "progress\n scanning files" in rendered + + +def test_format_transcript_text_marks_typed_runtime_entries() -> None: + rendered = format_transcript_text( + [ + TranscriptEntry( + id=1, + kind="progress", + body="Turn completed with verification evidence.", + category="runtime", + ), + TranscriptEntry(id=2, kind="progress", body="scanning files"), + ] + ) + + assert "runtime\n Turn completed with verification evidence." in rendered + assert "progress\n scanning files" in rendered + + +def test_format_transcript_text_surfaces_runtime_metadata() -> None: + rendered = format_transcript_text( + [ + TranscriptEntry( + id=1, + kind="progress", + body="Verification guard is holding the turn open.", + category="runtime", + runtimeKind="guard", + runtimeStep=6, + runtimePhase="verify", + runtimeStopReason="verification_failed", + runtimeVerificationFocus="tool_evidence", + ) + ] + ) + + assert "runtime:guard [step=6 phase=verify reason=verification_failed verify=tool_evidence]" in rendered + assert " Verification guard is holding the turn open." in rendered + + +def test_format_transcript_text_adds_runtime_summary_timeline() -> None: + entries = [ + TranscriptEntry( + id=1, + kind="progress", + body="Runtime phase: explore.", + category="runtime", + runtimeKind="phase", + runtimeStep=1, + runtimePhase="explore", + ), + TranscriptEntry( + id=2, + kind="progress", + body="Verification guard is holding the turn open.", + category="runtime", + runtimeKind="guard", + runtimeStep=4, + runtimePhase="verify", + runtimeStopReason="verification_failed", + runtimeVerificationFocus="tool_evidence", + ), + TranscriptEntry( + id=3, + kind="progress", + body="Widening is now available.", + category="runtime", + runtimeKind="widening", + runtimeStep=7, + runtimeStopReason="widen_needed", + ), + TranscriptEntry( + id=4, + kind="progress", + body="Turn complete.", + category="runtime", + runtimeKind="stop", + runtimeStep=8, + runtimeStopReason="done", + ), + ] + rendered = format_transcript_text(entries) + + assert rendered.startswith( + "runtime-summary\n phase:explore@1 -> guard:tool_evidence@4 -> widen:widen_needed@7 -> stop:done@8" + ) + assert "\n\n---\n\nruntime:phase" in rendered + assert ( + format_runtime_summary_line(entries) + == "runtime-summary: phase:explore@1 -> guard:tool_evidence@4 -> widen:widen_needed@7 -> stop:done@8" + ) + + +def test_decorate_session_feed_body_prepends_runtime_summary() -> None: + entries = [ + TranscriptEntry( + id=1, + kind="progress", + body="Runtime phase: verify.", + category="runtime", + runtimeKind="phase", + runtimeStep=2, + runtimePhase="verify", + ), + TranscriptEntry( + id=2, + kind="progress", + body="Turn complete.", + category="runtime", + runtimeKind="stop", + runtimeStep=3, + runtimeStopReason="done", + ), + ] + + rendered = _decorate_session_feed_body("assistant\n finished", entries) + + assert "runtime-summary: phase:verify@2 -> stop:done@3" in rendered + assert rendered.endswith("assistant\n finished") + + +def test_decorate_session_feed_body_prepends_checkpoint_summary() -> None: + session = SimpleNamespace( + checkpoints=[ + SimpleNamespace( + checkpoint_id="abcd1234efgh5678", + file_path="D:/tmp/alpha.py", + ), + SimpleNamespace( + checkpoint_id="wxyz9876ijkl5432", + file_path="D:/tmp/beta.py", + ), + ] + ) + + rendered = _decorate_session_feed_body( + "assistant\n finished", + [], + session, + ) + + assert "checkpoint-summary: 2 saved; latest [wxyz9876] beta.py, [abcd1234] alpha.py" in rendered + assert rendered.endswith("assistant\n finished") + + +def test_decorate_session_feed_body_prepends_product_summaries() -> None: + session = SimpleNamespace( + checkpoints=[], + metadata=SimpleNamespace( + readiness_summary="readiness: blocked (anthropic-compatible) [Missing provider channel]", + instruction_summary="instructions: 2 active layer(s) [global:user, project:managed]", + hook_summary="hooks: 1/2 enabled, 4 call(s), 18ms total", + delegation_summary="delegation: 1 running, 3/4 slots free [lint-worker]", + extension_summary="extensions: 1/1 enabled (1 project, 0 global)", + ), + ) + + rendered = _decorate_session_feed_body( + "assistant\n finished", + [], + session, + ) + + assert "readiness-summary: readiness: blocked (anthropic-compatible) [Missing provider channel]" in rendered + assert "instruction-summary: instructions: 2 active layer(s) [global:user, project:managed]" in rendered + assert "hook-summary: hooks: 1/2 enabled, 4 call(s), 18ms total" in rendered + assert "delegation-summary: delegation: 1 running, 3/4 slots free [lint-worker]" in rendered + assert "extension-summary: extensions: 1/1 enabled (1 project, 0 global)" in rendered + assert rendered.endswith("assistant\n finished") + + +def test_finalize_tty_session_persists_runtime_metadata() -> None: + session = SimpleNamespace( + session_id="session-1234", + messages=[], + transcript_entries=[], + history=[], + permissions_summary=None, + skills=[], + mcp_servers=[], + ) + args = SimpleNamespace( + messages=[], + permissions=SimpleNamespace(get_summary=lambda: {"mode": "default"}), + tools=SimpleNamespace(get_skills=lambda: ["runtime"], get_mcp_servers=lambda: ["memory"]), + ) + state = SimpleNamespace( + session=session, + transcript=[ + TranscriptEntry( + id=1, + kind="progress", + body="Widened mode is active.", + category="runtime", + runtimeKind="widening", + runtimeStep=7, + runtimePhase="verify", + runtimeStopReason="widen_needed", + runtimeVerificationFocus="comparison", + ) + ], + history=["continue"], + autosave=SimpleNamespace(force_save=lambda: None), + ) + + finalize_tty_session(args, state) + + assert state.session.transcript_entries == [ + { + "id": 1, + "kind": "progress", + "category": "runtime", + "runtimeKind": "widening", + "runtimeStep": 7, + "runtimePhase": "verify", + "runtimeStopReason": "widen_needed", + "runtimeVerificationFocus": "comparison", + "toolName": None, + "status": None, + "body": "Widened mode is active.", + "collapsed": False, + "collapsedSummary": None, + "collapsePhase": None, + } + ] + + def test_summarize_tool_input_formats_patch_file() -> None: summary = summarize_tool_input( "patch_file", @@ -183,3 +425,246 @@ def fake_run_agent_turn(**kwargs): assert captured["context_manager"] is context_manager assert saved == [context_manager] assert state.agent_result["messages"][-1] == {"role": "assistant", "content": "done"} + + +def test_tty_session_command_uses_live_session_snapshot(tmp_path) -> None: + session = SessionData( + session_id="session-1234", + created_at=1.0, + updated_at=2.0, + workspace=str(tmp_path), + ) + state = ScreenState( + input="/session", + cursor_offset=len("/session"), + transcript=[ + TranscriptEntry( + id=1, + kind="progress", + body="Runtime phase: verify.", + category="runtime", + runtimeKind="phase", + runtimeStep=2, + runtimePhase="verify", + ) + ], + history=["continue"], + session=session, + ) + args = TtyAppArgs( + runtime={"model": "default"}, + tools=ToolRegistry([]), + model=object(), + messages=[{"role": "user", "content": "continue"}], + cwd=str(tmp_path), + permissions=PermissionManager(str(tmp_path)), + ) + + assert input_handler_module._handle_input(args, state, lambda: None) is False + + assert state.transcript[-1].kind == "assistant" + assert "Session inspect: session-" in state.transcript[-1].body + assert "Runtime: phase:verify@2" in state.transcript[-1].body + assert state.session.transcript_entries[0]["runtimeKind"] == "phase" + + +def test_tty_sessions_command_lists_workspace_history(tmp_path, monkeypatch) -> None: + workspace = str(tmp_path.resolve()) + monkeypatch.setattr( + "minicode.cli_commands.list_sessions", + lambda: [ + SessionMetadata( + session_id="aaa111111111", + created_at=1.0, + updated_at=2.0, + first_message="alpha", + message_count=2, + workspace=workspace, + ) + ], + raising=False, + ) + state = ScreenState(input="/sessions", cursor_offset=len("/sessions")) + args = TtyAppArgs( + runtime={"model": "default"}, + tools=ToolRegistry([]), + model=object(), + messages=[], + cwd=workspace, + permissions=PermissionManager(workspace), + ) + + assert input_handler_module._handle_input(args, state, lambda: None) is False + + assert state.transcript[-1].kind == "assistant" + assert "Saved sessions:" in state.transcript[-1].body + assert "aaa11111" in state.transcript[-1].body + + +def test_tty_checkpoints_command_lists_active_session_checkpoints(tmp_path) -> None: + session = SessionData( + session_id="session-1234", + created_at=1.0, + updated_at=2.0, + workspace=str(tmp_path), + checkpoints=[ + FileCheckpoint( + checkpoint_id="abc123456789", + created_at=3.0, + file_path=str(tmp_path / "demo.txt"), + existed=True, + previous_content="before", + ) + ], + ) + session.update_metadata() + state = ScreenState( + input="/checkpoints", + cursor_offset=len("/checkpoints"), + session=session, + ) + args = TtyAppArgs( + runtime={"model": "default"}, + tools=ToolRegistry([]), + model=object(), + messages=[], + cwd=str(tmp_path), + permissions=PermissionManager(str(tmp_path)), + ) + + assert input_handler_module._handle_input(args, state, lambda: None) is False + + assert state.transcript[-1].kind == "assistant" + assert "Checkpoints for session session-" in state.transcript[-1].body + assert "[abc12345]" in state.transcript[-1].body + + +def test_tty_rewind_command_rewinds_active_session(tmp_path, monkeypatch) -> None: + checkpoint = FileCheckpoint( + checkpoint_id="abc123456789", + created_at=3.0, + file_path=str(tmp_path / "demo.txt"), + existed=True, + previous_content="before", + ) + session = SessionData( + session_id="session-1234", + created_at=1.0, + updated_at=2.0, + workspace=str(tmp_path), + checkpoints=[checkpoint], + ) + session.update_metadata() + + def fake_rewind(session_arg, *, steps=1, checkpoint_id=None): + assert session_arg is session + assert steps == 1 + assert checkpoint_id is None + session_arg.checkpoints = [] + session_arg.update_metadata() + return [checkpoint] + + monkeypatch.setattr("minicode.cli_commands.rewind_session_data", fake_rewind) + + state = ScreenState( + input="/rewind", + cursor_offset=len("/rewind"), + session=session, + ) + args = TtyAppArgs( + runtime={"model": "default"}, + tools=ToolRegistry([]), + model=object(), + messages=[], + cwd=str(tmp_path), + permissions=PermissionManager(str(tmp_path)), + ) + + assert input_handler_module._handle_input(args, state, lambda: None) is False + + assert state.transcript[-1].kind == "assistant" + assert "Rewound 1 checkpoint(s) for session session-" in state.transcript[-1].body + assert "Restored: [abc12345] demo.txt" in state.transcript[-1].body + assert "Resuming session session-" in state.transcript[-1].body + + +def test_tty_session_rewind_command_rewinds_saved_session(tmp_path, monkeypatch) -> None: + workspace = str(tmp_path.resolve()) + session = SessionData( + session_id="aaa111111111", + created_at=1.0, + updated_at=2.0, + workspace=workspace, + ) + checkpoint = FileCheckpoint( + checkpoint_id="abc123456789", + created_at=3.0, + file_path=str(tmp_path / "demo.txt"), + existed=True, + previous_content="before", + ) + session.checkpoints = [checkpoint] + session.update_metadata() + monkeypatch.setattr( + "minicode.cli_commands.get_latest_session", + lambda workspace=None: session if workspace == str(tmp_path.resolve()) else None, + raising=False, + ) + + def fake_rewind(session_id, *, steps=1, checkpoint_id=None): + assert session_id == session.session_id + assert steps == 1 + assert checkpoint_id is None + session.checkpoints = [] + session.update_metadata() + return session, [checkpoint] + + monkeypatch.setattr("minicode.cli_commands.rewind_session", fake_rewind) + + state = ScreenState(input="/session-rewind latest", cursor_offset=len("/session-rewind latest")) + args = TtyAppArgs( + runtime={"model": "default"}, + tools=ToolRegistry([]), + model=object(), + messages=[], + cwd=workspace, + permissions=PermissionManager(workspace), + ) + + assert input_handler_module._handle_input(args, state, lambda: None) is False + + assert state.transcript[-1].kind == "assistant" + assert "Rewound 1 checkpoint(s) for session aaa11111" in state.transcript[-1].body + assert "Restored: [abc12345] demo.txt" in state.transcript[-1].body + assert "Resuming session aaa11111" in state.transcript[-1].body + + +def test_tty_session_replay_command_lists_saved_timeline(tmp_path, monkeypatch) -> None: + workspace = str(tmp_path.resolve()) + monkeypatch.setattr( + "minicode.cli_commands.get_latest_session", + lambda workspace=None: SessionData( + session_id="aaa111111111", + created_at=1.0, + updated_at=2.0, + workspace=workspace, + history=["check the runtime timeline"], + transcript_entries=[{"kind": "assistant", "body": "restored"}], + ), + raising=False, + ) + state = ScreenState(input="/session-replay latest", cursor_offset=len("/session-replay latest")) + args = TtyAppArgs( + runtime={"model": "default"}, + tools=ToolRegistry([]), + model=object(), + messages=[], + cwd=workspace, + permissions=PermissionManager(workspace), + ) + + assert input_handler_module._handle_input(args, state, lambda: None) is False + + assert state.transcript[-1].kind == "assistant" + assert "Session replay: aaa11111" in state.transcript[-1].body + assert "Prompt history (1 shown):" in state.transcript[-1].body diff --git a/tests/test_turn_kernel.py b/tests/test_turn_kernel.py new file mode 100644 index 0000000..5f84a18 --- /dev/null +++ b/tests/test_turn_kernel.py @@ -0,0 +1,243 @@ +from minicode.task_object import TaskState +from minicode.turn_kernel import ( + TurnBudgetSignals, + TurnRecurrentState, + TurnVerificationState, + build_stable_task_pack, + decide_assistant_turn, + decide_tool_turn, + derive_turn_step_policy, +) + + +class DummyTask: + title = "Repair reader" + goal = "Keep durable state stable" + description = "Refactor the turn kernel" + + +class DummySlotState: + value = "running" + + +class DummySlot: + state = DummySlotState() + + +class DummyTaskGraph: + slots = {"turn:task-1": DummySlot()} + + def get_progress_percentage(self) -> float: + return 35.0 + + +def test_turn_recurrent_state_maps_await_user_to_paused() -> None: + turn_state = TurnRecurrentState(max_steps=5) + turn_state.set_stop_reason("await_user") + + assert turn_state.final_task_state() is TaskState.PAUSED + + +def test_build_stable_task_pack_includes_graph_and_protected_context() -> None: + pack = build_stable_task_pack( + task=DummyTask(), + task_metadata={"intent_type": "code", "action_type": "update"}, + protected_context=["user asked for durable state retention"], + task_graph=DummyTaskGraph(), + task_slot_key="turn:task-1", + latest_tool_result_summary="read_file: loaded turn kernel", + progress_state={"summary": "patched the assistant decision path"}, + verification_state=TurnVerificationState( + strict=True, + requires_explicit_final=True, + last_verification_note="need explicit final answer", + ), + budget_signals=TurnBudgetSignals( + remaining_steps=7, + tool_error_count=1, + saw_tool_result=True, + ), + ) + + assert pack is not None + text = pack.to_protected_text() + assert "Task graph: progress=35%" in text + assert "slot=running" in text + assert "Latest tool result: read_file: loaded turn kernel" in text + assert "Protected context:" in text + + +def test_derive_turn_step_policy_becomes_verification_heavy_late_in_single_deep() -> None: + turn_state = TurnRecurrentState( + max_steps=8, + profile_name="single-deep", + widen_after_step=6, + verification_state=TurnVerificationState(strict=True), + ) + turn_state.step = 6 + turn_state.saw_tool_result = True + turn_state._refresh_budget_signals() + + policy = derive_turn_step_policy(turn_state) + + assert policy.phase == "verify" + assert turn_state.verification_state.requires_explicit_final is True + assert "phase=verify" in turn_state.verification_state.last_verification_note + + +def test_derive_turn_step_policy_allows_widening_after_stall_threshold() -> None: + turn_state = TurnRecurrentState( + max_steps=10, + profile_name="single-deep", + widen_after_step=4, + verification_state=TurnVerificationState(strict=True), + ) + turn_state.step = 5 + turn_state.tool_error_count = 2 + turn_state._refresh_budget_signals() + + policy = derive_turn_step_policy(turn_state) + + assert policy.allow_widening is True + assert "widening=ready" in turn_state.verification_state.last_verification_note + + +def test_derive_turn_step_policy_requires_explicit_signal_before_widening() -> None: + turn_state = TurnRecurrentState( + max_steps=10, + profile_name="single-deep", + widen_after_step=4, + verification_state=TurnVerificationState(strict=True), + ) + turn_state.step = 5 + turn_state._refresh_budget_signals() + + policy = derive_turn_step_policy(turn_state) + + assert policy.allow_widening is False + assert policy.widening_reason == "" + + +def test_derive_turn_step_policy_records_model_stall_as_widening_reason() -> None: + turn_state = TurnRecurrentState( + max_steps=10, + profile_name="single-deep", + widen_after_step=4, + empty_response_retry_limit=3, + verification_state=TurnVerificationState(strict=True), + ) + turn_state.step = 5 + turn_state.empty_response_retry_count = 3 + turn_state._refresh_budget_signals() + + policy = derive_turn_step_policy(turn_state) + + assert policy.allow_widening is True + assert "stalled repeatedly" in policy.widening_reason + assert "assistant returned repeated empty responses" in policy.widening_evidence_summary + + +def test_turn_recurrent_state_widening_transition_extends_budget_once() -> None: + turn_state = TurnRecurrentState( + max_steps=8, + profile_name="single-deep", + widen_after_step=4, + ) + turn_state.step = 6 + turn_state._refresh_budget_signals() + + first = turn_state.activate_widening(extra_steps=5) + second = turn_state.activate_widening(extra_steps=5) + + assert first is True + assert second is False + assert turn_state.widening_active is True + assert turn_state.widening_transition_count == 1 + assert turn_state.max_steps == 13 + + +def test_decide_assistant_turn_returns_verification_failed_in_late_verify_mode() -> None: + turn_state = TurnRecurrentState( + max_steps=8, + profile_name="single-deep", + verification_state=TurnVerificationState( + strict=True, + requires_explicit_final=True, + ), + ) + turn_state.step = 3 + turn_state._refresh_budget_signals() + turn_state.saw_tool_result = True + turn_state.empty_response_retry_count = turn_state.empty_response_retry_limit + + decision = decide_assistant_turn( + turn_state=turn_state, + step_content="", + step_kind=None, + stop_reason=None, + block_types=None, + ignored_block_types=None, + is_empty=True, + treat_as_progress=False, + is_recoverable_thinking_stop=False, + format_diagnostics=lambda *_: "", + nudge_continue="continue", + nudge_after_tool_result="after tool", + resume_after_pause="resume pause", + resume_after_max_tokens="resume tokens", + nudge_after_empty_response="empty after tool", + nudge_after_empty_no_tools="empty no tools", + step_policy=derive_turn_step_policy(turn_state), + ) + + assert decision.kind == "fallback" + assert decision.stop_reason == "verification_failed" + assert "verification failure" in (decision.assistant_content or "").lower() + + +def test_decide_assistant_turn_rejects_unsupported_final_in_verify_mode() -> None: + turn_state = TurnRecurrentState( + max_steps=8, + profile_name="single-deep", + verification_state=TurnVerificationState( + strict=True, + requires_explicit_final=True, + ), + ) + turn_state.step = 4 + turn_state.record_tool_result(True, summary="pytest: 5 passed") + + decision = decide_assistant_turn( + turn_state=turn_state, + step_content="Done, the fix is complete.", + step_kind=None, + stop_reason=None, + block_types=None, + ignored_block_types=None, + is_empty=False, + treat_as_progress=False, + is_recoverable_thinking_stop=False, + format_diagnostics=lambda *_: "", + nudge_continue="continue", + nudge_after_tool_result="after tool", + resume_after_pause="resume pause", + resume_after_max_tokens="resume tokens", + nudge_after_empty_response="empty after tool", + nudge_after_empty_no_tools="empty no tools", + step_policy=derive_turn_step_policy(turn_state), + ) + + assert decision.kind == "progress" + assert "verification guard" in (decision.assistant_content or "").lower() + assert "pytest: 5 passed" in (decision.user_content or "") + + +def test_decide_tool_turn_keeps_await_user_typed() -> None: + decision = decide_tool_turn( + tool_name="ask_user", + result_output="Need approval", + await_user=True, + ) + + assert decision.kind == "await_user" + assert decision.stop_reason == "await_user" diff --git a/tests/test_verification_controller.py b/tests/test_verification_controller.py new file mode 100644 index 0000000..24b8435 --- /dev/null +++ b/tests/test_verification_controller.py @@ -0,0 +1,115 @@ +from minicode.intent_parser import ActionType, IntentType, ParsedIntent +from minicode.pipeline_engine import Step, StepExecutor, StepType +from minicode.task_object import ConstraintType, TaskObject +from minicode.verification_controller import ( + VerificationController, + VerificationMode, + VerificationRisk, + VerificationSignal, +) + + +def _task( + *, + intent_type: IntentType = IntentType.CODE, + action_type: ActionType = ActionType.UPDATE, + files: list[str] | None = None, +) -> TaskObject: + task = TaskObject( + raw_input="update code", + parsed_intent=ParsedIntent( + raw_input="update code", + intent_type=intent_type, + action_type=action_type, + confidence=1.0, + ), + relevant_files=files or [], + ) + if intent_type in {IntentType.CODE, IntentType.DEBUG, IntentType.REFACTOR, IntentType.TEST}: + task.add_constraint(ConstraintType.TEST_REQUIRED, reason="Code modification requires tests") + return task + + +class TestVerificationController: + def test_read_only_task_uses_no_verification(self): + controller = VerificationController() + plan = controller.plan( + VerificationSignal(intent_type="question", action_type="read", changed_files=[]) + ) + assert plan.risk == VerificationRisk.LOW + assert plan.mode == VerificationMode.NONE + assert plan.commands == [] + + def test_python_core_change_selects_targeted_tests(self): + controller = VerificationController() + plan = controller.plan( + VerificationSignal( + changed_files=["minicode/context_cybernetics.py"], + intent_type="code", + action_type="update", + requires_tests=True, + ) + ) + assert plan.risk in {VerificationRisk.HIGH, VerificationRisk.CRITICAL} + assert plan.mode in {VerificationMode.TARGETED, VerificationMode.FULL} + assert any( + "test_context_cybernetics.py" in cmd or cmd == "pytest -q" + for cmd in plan.commands + ) + + def test_previous_failure_escalates_to_full_verification(self): + controller = VerificationController() + plan = controller.plan( + VerificationSignal( + changed_files=["minicode/agent_loop.py", "tests/test_agent_loop.py"], + intent_type="debug", + action_type="update", + requires_tests=True, + previous_verification_failed=True, + recent_failures=3, + ) + ) + assert plan.risk == VerificationRisk.CRITICAL + assert plan.mode == VerificationMode.FULL + assert plan.commands == ["pytest -q"] + + def test_documentation_change_does_not_force_tests(self): + controller = VerificationController() + plan = controller.plan( + VerificationSignal( + changed_files=["docs/architecture.md"], + intent_type="document", + action_type="update", + ) + ) + assert plan.mode == VerificationMode.NONE + assert plan.should_run is False + + def test_feedback_signal_marks_failed_plan_for_escalation(self): + controller = VerificationController() + plan = controller.plan( + VerificationSignal(changed_files=["minicode/model_registry.py"], intent_type="code") + ) + feedback = controller.update_from_result(plan, passed=False) + assert feedback.previous_verification_failed is True + assert feedback.recent_failures == 1 + assert feedback.changed_files == plan.changed_files + + +class TestVerificationPipelineIntegration: + def test_pipeline_verify_step_returns_risk_adaptive_plan(self): + executor = StepExecutor() + task = _task(files=["minicode/context_cybernetics.py"]) + step = Step( + id="verify", + type=StepType.VERIFY, + description="Verify correctness with tests", + handler="run_tests", + ) + + success, result = executor.execute(step, task) + + assert success is True + assert result["tests_passed"] is None + assert result["verification_plan"]["mode"] in {"targeted", "full"} + assert any("pytest" in cmd for cmd in result["verification_plan"]["commands"])