diff --git a/src/autoskillit/execution/session_log.py b/src/autoskillit/execution/session_log.py index 3ca1e5eee1..732d5189d9 100644 --- a/src/autoskillit/execution/session_log.py +++ b/src/autoskillit/execution/session_log.py @@ -16,13 +16,12 @@ from typing import TYPE_CHECKING, Any, Literal if TYPE_CHECKING: - from autoskillit.core import ProviderOutcome, RecipeIdentity, SessionTelemetry + from autoskillit.core import ProviderOutcome, RecipeIdentity, SessionLocator, SessionTelemetry from autoskillit.core import ( ModelIdentity, atomic_write, - claude_code_log_path, default_log_dir, get_logger, iter_merged_assistant_turns, @@ -155,6 +154,7 @@ def flush_session_log( codex_log_path: Path | None = None, backend: Literal["claude-code", "codex"] = "claude-code", telemetry: SessionTelemetry, + session_locator: SessionLocator | None = None, ) -> None: """Flush session diagnostics to disk. @@ -187,7 +187,13 @@ def flush_session_log( cc_log = None cc_log_str = None else: - cc_log = claude_code_log_path(cwd, session_id) + if session_locator is not None: + _locator = session_locator + else: + from autoskillit.execution.backends.claude import ClaudeSessionLocator + + _locator = ClaudeSessionLocator() + cc_log = _locator.session_log_path(cwd, session_id) cc_log_str = str(cc_log) if cc_log else None if cc_log and not cc_log.exists(): diff --git a/tests/execution/conftest.py b/tests/execution/conftest.py index 029ccac02c..dee9e745c8 100644 --- a/tests/execution/conftest.py +++ b/tests/execution/conftest.py @@ -12,7 +12,7 @@ import pytest -from autoskillit.core import BackendCapabilities, CmdSpec +from autoskillit.core import BackendCapabilities, CmdSpec, SessionLocator from autoskillit.core.types import SubprocessResult, TerminationReason from autoskillit.execution.session import ClaudeSessionResult from autoskillit.execution.session._exit_classification import _CODEX_API_ERROR_PATTERNS @@ -310,7 +310,13 @@ def _subagent_assistant_ndjson( ) -def _flush(tmp_path: Path, *, backend: str = "claude-code", **overrides) -> None: +def _flush( + tmp_path: Path, + *, + backend: str = "claude-code", + session_locator: SessionLocator | None = None, + **overrides, +) -> None: from autoskillit.core.types._type_results import ModelIdentity, ProviderOutcome from autoskillit.core.types._type_results_execution import ( RecipeIdentity, @@ -393,6 +399,7 @@ def _flush(tmp_path: Path, *, backend: str = "claude-code", **overrides) -> None provider_outcome=provider_outcome, recipe_identity=recipe_identity, backend=backend, + session_locator=session_locator, ) diff --git a/tests/execution/test_flush_completeness_guard.py b/tests/execution/test_flush_completeness_guard.py index 5610a5a516..3fa540fe75 100644 --- a/tests/execution/test_flush_completeness_guard.py +++ b/tests/execution/test_flush_completeness_guard.py @@ -56,6 +56,14 @@ def test_recipe_identity_is_required_parameter(self): param = sig.parameters["recipe_identity"] assert param.default is inspect.Parameter.empty + def test_session_locator_is_optional_parameter(self): + from autoskillit.execution.session_log import flush_session_log + + sig = inspect.signature(flush_session_log) + assert "session_locator" in sig.parameters + param = sig.parameters["session_locator"] + assert param.default is None + class TestFlushOutputCompleteness: """Every ProviderOutcome field must appear in flush output.""" diff --git a/tests/execution/test_session_log_fields.py b/tests/execution/test_session_log_fields.py index 0ba780168a..bcb76bbaf9 100644 --- a/tests/execution/test_session_log_fields.py +++ b/tests/execution/test_session_log_fields.py @@ -7,6 +7,7 @@ import json import os from datetime import UTC, datetime +from pathlib import Path import pytest @@ -29,6 +30,22 @@ pytestmark = [pytest.mark.layer("execution"), pytest.mark.medium] +class _FakeLocator: + """SessionLocator fake — returns a fixed path for session_log_path.""" + + def __init__(self, path: Path | None) -> None: + self._path = path + + def locate_session(self, session_id: str) -> Path | None: + return self._path + + def project_log_dir(self, cwd: str) -> Path: + return Path(cwd) + + def session_log_path(self, cwd: str, session_id: str) -> Path | None: + return self._path + + def test_flush_session_log_includes_write_path_warnings_in_summary(tmp_path): """summary.json records write_path_warnings list.""" warnings = [ @@ -222,7 +239,7 @@ def test_flush_session_log_no_raw_stdout_on_success(tmp_path): assert not raw_file.exists() -def test_flush_session_log_summary_contains_per_turn_fields(tmp_path, monkeypatch): +def test_flush_session_log_summary_contains_per_turn_fields(tmp_path): cb_log = tmp_path / "s.jsonl" cb_log.write_text( json.dumps( @@ -234,10 +251,6 @@ def test_flush_session_log_summary_contains_per_turn_fields(tmp_path, monkeypatc ) + "\n" ) - import autoskillit.execution.session_log as sl_mod - - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) - flush_session_log( log_dir=str(tmp_path), cwd="/tmp", @@ -253,6 +266,7 @@ def test_flush_session_log_summary_contains_per_turn_fields(tmp_path, monkeypatc telemetry=SessionTelemetry.empty(), provider_outcome=ProviderOutcome.none_used(), recipe_identity=RecipeIdentity.empty(), + session_locator=_FakeLocator(cb_log), ) summary = json.loads((tmp_path / "sessions" / "s" / "summary.json").read_text()) assert summary["last_stop_reason"] == "end_turn" @@ -260,7 +274,7 @@ def test_flush_session_log_summary_contains_per_turn_fields(tmp_path, monkeypatc assert summary["turn_timestamps"] == ["2026-04-15T07:00:00Z", "2026-04-15T07:00:05Z"] -def test_flush_session_log_includes_no_request_id_turns(tmp_path, monkeypatch): +def test_flush_session_log_includes_no_request_id_turns(tmp_path): cb_log = tmp_path / "s.jsonl" cb_log.write_text( _make_cc_jsonl_record( @@ -275,9 +289,6 @@ def test_flush_session_log_includes_no_request_id_turns(tmp_path, monkeypatch): ) + "\n" ) - import autoskillit.execution.session_log as sl_mod - - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) flush_session_log( log_dir=str(tmp_path), cwd="/tmp", @@ -292,6 +303,7 @@ def test_flush_session_log_includes_no_request_id_turns(tmp_path, monkeypatch): telemetry=SessionTelemetry.empty(), provider_outcome=ProviderOutcome.none_used(), recipe_identity=RecipeIdentity.empty(), + session_locator=_FakeLocator(cb_log), ) summary = json.loads((tmp_path / "sessions" / "s" / "summary.json").read_text()) assert len(summary["turn_timestamps"]) == 2 @@ -306,7 +318,7 @@ def test_flush_session_log_includes_no_request_id_turns(tmp_path, monkeypatch): ) -def test_flush_session_log_all_no_rid_turns_still_recorded(tmp_path, monkeypatch): +def test_flush_session_log_all_no_rid_turns_still_recorded(tmp_path): cb_log = tmp_path / "s.jsonl" cb_log.write_text( _make_cc_jsonl_record( @@ -325,9 +337,6 @@ def test_flush_session_log_all_no_rid_turns_still_recorded(tmp_path, monkeypatch ) + "\n" ) - import autoskillit.execution.session_log as sl_mod - - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) flush_session_log( log_dir=str(tmp_path), cwd="/tmp", @@ -342,6 +351,7 @@ def test_flush_session_log_all_no_rid_turns_still_recorded(tmp_path, monkeypatch telemetry=SessionTelemetry.empty(), provider_outcome=ProviderOutcome.none_used(), recipe_identity=RecipeIdentity.empty(), + session_locator=_FakeLocator(cb_log), ) summary = json.loads((tmp_path / "sessions" / "s" / "summary.json").read_text()) assert len(summary["turn_timestamps"]) == 3 @@ -355,7 +365,7 @@ def test_flush_session_log_all_no_rid_turns_still_recorded(tmp_path, monkeypatch assert all(rid.startswith("turn-") for rid in summary["request_ids"]) -def test_channel_b_turn_count_bounded_by_channel_a(tmp_path, monkeypatch): +def test_channel_b_turn_count_bounded_by_channel_a(tmp_path): cb_log = tmp_path / "s.jsonl" cb_log.write_text( _make_cc_jsonl_record( @@ -369,9 +379,6 @@ def test_channel_b_turn_count_bounded_by_channel_a(tmp_path, monkeypatch): ) + "\n" ) - import autoskillit.execution.session_log as sl_mod - - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) telemetry = SessionTelemetry( token_usage={"input_tokens": 0, "output_tokens": 0, "turn_count": 2}, timing_seconds=None, @@ -395,6 +402,7 @@ def test_channel_b_turn_count_bounded_by_channel_a(tmp_path, monkeypatch): telemetry=telemetry, provider_outcome=ProviderOutcome.none_used(), recipe_identity=RecipeIdentity.empty(), + session_locator=_FakeLocator(cb_log), ) summary = json.loads((tmp_path / "sessions" / "s" / "summary.json").read_text()) tu = json.loads((tmp_path / "sessions" / "s" / "token_usage.json").read_text()) @@ -403,7 +411,7 @@ def test_channel_b_turn_count_bounded_by_channel_a(tmp_path, monkeypatch): assert turn_count >= len(summary["turn_timestamps"]) -def test_parallel_lists_aligned_mixed_rid_no_rid(tmp_path, monkeypatch): +def test_parallel_lists_aligned_mixed_rid_no_rid(tmp_path): cb_log = tmp_path / "s.jsonl" cb_log.write_text( _make_cc_jsonl_record( @@ -435,9 +443,6 @@ def test_parallel_lists_aligned_mixed_rid_no_rid(tmp_path, monkeypatch): ) + "\n" ) - import autoskillit.execution.session_log as sl_mod - - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) flush_session_log( log_dir=str(tmp_path), cwd="/tmp", @@ -452,6 +457,7 @@ def test_parallel_lists_aligned_mixed_rid_no_rid(tmp_path, monkeypatch): telemetry=SessionTelemetry.empty(), provider_outcome=ProviderOutcome.none_used(), recipe_identity=RecipeIdentity.empty(), + session_locator=_FakeLocator(cb_log), ) summary = json.loads((tmp_path / "sessions" / "s" / "summary.json").read_text()) assert ( @@ -480,7 +486,7 @@ def test_parallel_lists_aligned_mixed_rid_no_rid(tmp_path, monkeypatch): # turn_tool_calls -def test_flush_session_log_summary_contains_turn_tool_calls(tmp_path, monkeypatch): +def test_flush_session_log_summary_contains_turn_tool_calls(tmp_path): cb_log = tmp_path / "s.jsonl" cb_log.write_text( json.dumps( @@ -497,10 +503,6 @@ def test_flush_session_log_summary_contains_turn_tool_calls(tmp_path, monkeypatc ) + "\n" ) - import autoskillit.execution.session_log as sl_mod - - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) - flush_session_log( log_dir=str(tmp_path), cwd="/tmp", @@ -515,12 +517,13 @@ def test_flush_session_log_summary_contains_turn_tool_calls(tmp_path, monkeypatc telemetry=SessionTelemetry.empty(), provider_outcome=ProviderOutcome.none_used(), recipe_identity=RecipeIdentity.empty(), + session_locator=_FakeLocator(cb_log), ) summary = json.loads((tmp_path / "sessions" / "s" / "summary.json").read_text()) assert summary["turn_tool_calls"] == [["ToolA", "ToolB"]] -def test_turn_tool_calls_capped_at_8_per_turn(tmp_path, monkeypatch): +def test_turn_tool_calls_capped_at_8_per_turn(tmp_path): tools = [{"type": "tool_use", "name": f"Tool{i}"} for i in range(10)] cb_log = tmp_path / "s.jsonl" cb_log.write_text( @@ -533,9 +536,6 @@ def test_turn_tool_calls_capped_at_8_per_turn(tmp_path, monkeypatch): ) + "\n" ) - import autoskillit.execution.session_log as sl_mod - - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) flush_session_log( log_dir=str(tmp_path), cwd="/tmp", @@ -550,13 +550,14 @@ def test_turn_tool_calls_capped_at_8_per_turn(tmp_path, monkeypatch): telemetry=SessionTelemetry.empty(), provider_outcome=ProviderOutcome.none_used(), recipe_identity=RecipeIdentity.empty(), + session_locator=_FakeLocator(cb_log), ) summary = json.loads((tmp_path / "sessions" / "s" / "summary.json").read_text()) assert len(summary["turn_tool_calls"][0]) == 8 assert summary["turn_tool_calls"][0] == [f"Tool{i}" for i in range(8)] -def test_turn_tool_calls_empty_for_text_only_turn(tmp_path, monkeypatch): +def test_turn_tool_calls_empty_for_text_only_turn(tmp_path): cb_log = tmp_path / "s.jsonl" cb_log.write_text( json.dumps( @@ -568,9 +569,6 @@ def test_turn_tool_calls_empty_for_text_only_turn(tmp_path, monkeypatch): ) + "\n" ) - import autoskillit.execution.session_log as sl_mod - - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) flush_session_log( log_dir=str(tmp_path), cwd="/tmp", @@ -585,12 +583,13 @@ def test_turn_tool_calls_empty_for_text_only_turn(tmp_path, monkeypatch): telemetry=SessionTelemetry.empty(), provider_outcome=ProviderOutcome.none_used(), recipe_identity=RecipeIdentity.empty(), + session_locator=_FakeLocator(cb_log), ) summary = json.loads((tmp_path / "sessions" / "s" / "summary.json").read_text()) assert summary["turn_tool_calls"] == [[]] -def test_turn_tool_calls_parallel_to_request_ids(tmp_path, monkeypatch): +def test_turn_tool_calls_parallel_to_request_ids(tmp_path): records = [ json.dumps( { @@ -603,9 +602,6 @@ def test_turn_tool_calls_parallel_to_request_ids(tmp_path, monkeypatch): ] cb_log = tmp_path / "s.jsonl" cb_log.write_text("\n".join(records) + "\n") - import autoskillit.execution.session_log as sl_mod - - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) flush_session_log( log_dir=str(tmp_path), cwd="/tmp", @@ -620,6 +616,7 @@ def test_turn_tool_calls_parallel_to_request_ids(tmp_path, monkeypatch): telemetry=SessionTelemetry.empty(), provider_outcome=ProviderOutcome.none_used(), recipe_identity=RecipeIdentity.empty(), + session_locator=_FakeLocator(cb_log), ) summary = json.loads((tmp_path / "sessions" / "s" / "summary.json").read_text()) assert len(summary["turn_tool_calls"]) == len(summary["request_ids"]) == 3 @@ -630,30 +627,36 @@ def test_turn_tool_calls_parallel_to_request_ids(tmp_path, monkeypatch): # --------------------------------------------------------------------------- -def test_summary_includes_silent_gap_seconds(tmp_path, monkeypatch): +def test_summary_includes_silent_gap_seconds(tmp_path): """silent_gap_seconds computed from cc_log mtime vs end_ts — approx 5.0s.""" - import autoskillit.execution.session_log as sl_mod - cb_log = tmp_path / "session.jsonl" cb_log.write_text("") end_ts = "2026-04-15T07:00:10+00:00" end_dt = datetime.fromisoformat(end_ts) os.utime(cb_log, (end_dt.timestamp() - 5.0,) * 2) - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) - _flush(tmp_path, session_id="gap-test", end_ts=end_ts, proc_snapshots=None) + _flush( + tmp_path, + session_id="gap-test", + end_ts=end_ts, + proc_snapshots=None, + session_locator=_FakeLocator(cb_log), + ) summary = json.loads((tmp_path / "sessions" / "gap-test" / "summary.json").read_text()) assert "silent_gap_seconds" in summary assert summary["silent_gap_seconds"] == pytest.approx(5.0, abs=0.5) -def test_summary_silent_gap_seconds_null_when_no_end_ts(tmp_path, monkeypatch): +def test_summary_silent_gap_seconds_null_when_no_end_ts(tmp_path): """silent_gap_seconds is null when end_ts is not provided.""" - import autoskillit.execution.session_log as sl_mod - cb_log = tmp_path / "session.jsonl" cb_log.write_text("") - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) - _flush(tmp_path, session_id="no-end-ts", end_ts="", proc_snapshots=None) + _flush( + tmp_path, + session_id="no-end-ts", + end_ts="", + proc_snapshots=None, + session_locator=_FakeLocator(cb_log), + ) summary = json.loads((tmp_path / "sessions" / "no-end-ts" / "summary.json").read_text()) assert summary["silent_gap_seconds"] is None @@ -671,11 +674,8 @@ def test_summary_silent_gap_seconds_null_when_cc_log_missing(tmp_path): assert summary["silent_gap_seconds"] is None -def test_flush_outcome_anomaly_included_in_anomaly_count(tmp_path, monkeypatch): +def test_flush_outcome_anomaly_included_in_anomaly_count(tmp_path): """empty_result + output_tokens > 0 increments anomaly_count in summary and index.""" - import autoskillit.execution.session_log as sl_mod - - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: None) _flush( tmp_path, session_id="outcome-anomaly", @@ -683,6 +683,7 @@ def test_flush_outcome_anomaly_included_in_anomaly_count(tmp_path, monkeypatch): success=False, token_usage={"output_tokens": 945, "input_tokens": 500}, proc_snapshots=None, + session_locator=_FakeLocator(None), ) summary = json.loads((tmp_path / "sessions" / "outcome-anomaly" / "summary.json").read_text()) assert summary["anomaly_count"] >= 1 @@ -951,7 +952,7 @@ def test_flush_session_log_provider_used_defaults_empty_in_token_usage(tmp_path) assert tu["provider_used"] == "" -def test_turn_tool_calls_merged_across_thinking_and_tool_records(tmp_path, monkeypatch): +def test_turn_tool_calls_merged_across_thinking_and_tool_records(tmp_path): cb_log = tmp_path / "s.jsonl" cb_log.write_text( _make_cc_jsonl_record( @@ -966,9 +967,6 @@ def test_turn_tool_calls_merged_across_thinking_and_tool_records(tmp_path, monke ) + "\n" ) - import autoskillit.execution.session_log as sl_mod - - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) flush_session_log( log_dir=str(tmp_path), cwd="/tmp", @@ -983,13 +981,14 @@ def test_turn_tool_calls_merged_across_thinking_and_tool_records(tmp_path, monke telemetry=SessionTelemetry.empty(), provider_outcome=ProviderOutcome.none_used(), recipe_identity=RecipeIdentity.empty(), + session_locator=_FakeLocator(cb_log), ) summary = json.loads((tmp_path / "sessions" / "s" / "summary.json").read_text()) assert summary["turn_tool_calls"] == [["Bash"]] assert summary["request_ids"] == ["req-001"] -def test_parallel_lists_aligned_when_timestamp_missing(tmp_path, monkeypatch): +def test_parallel_lists_aligned_when_timestamp_missing(tmp_path): cb_log = tmp_path / "s.jsonl" cb_log.write_text( _make_cc_jsonl_record( @@ -1004,9 +1003,6 @@ def test_parallel_lists_aligned_when_timestamp_missing(tmp_path, monkeypatch): ) + "\n" ) - import autoskillit.execution.session_log as sl_mod - - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) flush_session_log( log_dir=str(tmp_path), cwd="/tmp", @@ -1021,6 +1017,7 @@ def test_parallel_lists_aligned_when_timestamp_missing(tmp_path, monkeypatch): telemetry=SessionTelemetry.empty(), provider_outcome=ProviderOutcome.none_used(), recipe_identity=RecipeIdentity.empty(), + session_locator=_FakeLocator(cb_log), ) summary = json.loads((tmp_path / "sessions" / "s" / "summary.json").read_text()) assert ( @@ -1661,7 +1658,7 @@ def test_rss_startup_artifact_suppressed_through_flush(self, tmp_path): assert len(rss) == 0 -def test_flush_session_log_minimax_message_id_turn_dedup(tmp_path, monkeypatch): +def test_flush_session_log_minimax_message_id_turn_dedup(tmp_path): """MiniMax-shaped JSONL (message.id, no requestId) deduplicates into correct turn count.""" mid = "0669d3ed14adce24ccf227c37a5884d4" cb_log = tmp_path / "s.jsonl" @@ -1685,9 +1682,6 @@ def test_flush_session_log_minimax_message_id_turn_dedup(tmp_path, monkeypatch): ) + "\n" ) - import autoskillit.execution.session_log as sl_mod - - monkeypatch.setattr(sl_mod, "claude_code_log_path", lambda cwd, sid: cb_log) flush_session_log( log_dir=str(tmp_path), cwd="/tmp", @@ -1702,6 +1696,7 @@ def test_flush_session_log_minimax_message_id_turn_dedup(tmp_path, monkeypatch): telemetry=SessionTelemetry.empty(), provider_outcome=ProviderOutcome.none_used(), recipe_identity=RecipeIdentity.empty(), + session_locator=_FakeLocator(cb_log), ) summary = json.loads( (tmp_path / "sessions" / "minimax-dedup-001" / "summary.json").read_text() diff --git a/tests/execution/test_session_log_flush.py b/tests/execution/test_session_log_flush.py index 2e9f524cad..0dbb859058 100644 --- a/tests/execution/test_session_log_flush.py +++ b/tests/execution/test_session_log_flush.py @@ -761,6 +761,26 @@ def _capture(**kwargs): assert telemetry.loc_deletions == 0 +def test_flush_helper_forwards_session_locator(): + """_flush() forwards session_locator= to flush_session_log when provided.""" + import tempfile + import unittest.mock as mock + + captured: dict = {} + + def _capture(**kwargs): + captured.update(kwargs) + + sentinel = object() + with mock.patch("autoskillit.execution.session_log.flush_session_log", side_effect=_capture): + with tempfile.TemporaryDirectory() as td: + _flush(Path(td), session_locator=sentinel) + + assert captured.get("session_locator") is sentinel, ( + "_flush() must forward session_locator= to flush_session_log" + ) + + def test_flush_writes_token_usage_with_dispatch_label(tmp_path): """token_usage.json uses dispatch:{id} label when step_name is empty and dispatch_id set.""" _flush(