Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Prefer using `uv` and existing Makefile targets. When adding new dependencies, u

## Configuration and Credentials

- The runtime configuration directory is `~/.iac-code/`, containing `.credentials.yml`, `.cloud-credentials.yml`, `settings.yml`, and input history.
- The runtime configuration directory defaults to `~/.iac-code/`, containing `.credentials.yml`, `.cloud-credentials.yml`, `settings.yml`, `.multimodal-cache.yml`, and input history. Override by setting `IAC_CODE_CONFIG_DIR` (supports `~` and `$VAR` expansion); all subdirectories (`projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/`) follow.
- Do not commit, print, or hard-code real API keys, AccessKeys, Secrets, tokens, cookies, or user configuration file contents.
- Alibaba Cloud credential-related tests must use fake values and avoid triggering real cloud APIs.

Expand Down
22 changes: 20 additions & 2 deletions src/iac_code/a2a/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
TASK_STATE_WORKING,
)
from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime
from iac_code.services.session_storage import SessionStorage

logger = logging.getLogger(__name__)
_CONTEXT_LOCK_ACQUIRE_TIMEOUT_SECONDS = 1
Expand Down Expand Up @@ -117,7 +118,19 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
return

def runtime_factory(session_id: str) -> Any:
return create_agent_runtime(AgentFactoryOptions(model=self._model, session_id=session_id, cwd=cwd))
session_storage = SessionStorage()
resume_messages = None
if session_storage.exists(cwd, session_id):
loaded = session_storage.load(cwd, session_id)
resume_messages = SessionStorage.repair_interrupted(loaded) if loaded else None
return create_agent_runtime(
AgentFactoryOptions(
model=self._model,
session_id=session_id,
cwd=cwd,
resume_messages=resume_messages,
)
)

try:
ctx = await self._task_store.get_or_create_context(
Expand Down Expand Up @@ -296,11 +309,16 @@ def _resolve_cwd(self, metadata: Any | None) -> str:
raw_cwd = raw_iac_meta.get("cwd")
if isinstance(raw_cwd, str):
cwd = raw_cwd
if not isinstance(cwd, str) or not Path(cwd).is_absolute() or not Path(cwd).is_dir():
if not isinstance(cwd, str) or not Path(cwd).is_absolute():
raise ValueError("Invalid A2A workspace metadata.")
resolved_cwd = Path(cwd).resolve()
if not any(_is_relative_to(resolved_cwd, root) for root in _allowed_cwd_roots()):
raise ValueError("Invalid A2A workspace metadata.")
if resolved_cwd.exists():
if not resolved_cwd.is_dir():
raise ValueError("Invalid A2A workspace metadata.")
else:
resolved_cwd.mkdir(parents=True, exist_ok=True)
return str(resolved_cwd)

def _prompt_from_context(self, context: RequestContext, *, cwd: str) -> str:
Expand Down
11 changes: 10 additions & 1 deletion src/iac_code/a2a/task_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,16 @@ async def get_or_create_context(
self._mirror_context(record)
return record

session_id = str(uuid.uuid4())
session_id: str | None = None
if self._persistence is not None:
snapshot = self._persistence.load_context(context_id)
if snapshot is not None:
if snapshot.cwd != cwd:
raise ValueError("A2A context belongs to a different workspace")
session_id = snapshot.session_id

if session_id is None:
session_id = str(uuid.uuid4())
record = A2AContextRecord(
context_id=context_id,
session_id=session_id,
Expand Down
29 changes: 24 additions & 5 deletions src/iac_code/config.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""Configuration paths for iac-code.

Provides unified configuration directory and file paths under
``~/.iac-code/``.
``~/.iac-code/`` by default. Can be relocated by setting the
``IAC_CODE_CONFIG_DIR`` environment variable (``~`` and ``$VAR``
expansion supported); when set, every persisted artifact follows.
"""

from __future__ import annotations

import os
from pathlib import Path
from typing import Any

Expand All @@ -16,6 +19,7 @@

# Configuration directory
_CONFIG_DIR_NAME = ".iac-code"
_CONFIG_DIR_ENV_VAR = "IAC_CODE_CONFIG_DIR"

# Configuration files
_CREDENTIALS_FILE = ".credentials.yml"
Expand Down Expand Up @@ -125,7 +129,6 @@ def _get_env_overrides() -> dict[str, str | None]:
to None. Invalid ``IAC_CODE_PROVIDER`` raises ``ValueError`` listing canonical
names.
"""
import os

def _read(name: str) -> str | None:
raw = os.environ.get(name, "")
Expand Down Expand Up @@ -171,12 +174,28 @@ def get_llm_source() -> str:
# ---------------------------------------------------------------------------


def _resolve_config_dir() -> Path:
"""Resolve the config directory path without creating it.

Honors ``IAC_CODE_CONFIG_DIR`` (with ``~`` and ``$VAR`` expansion).
Empty / whitespace-only values are treated as unset.
"""
raw = os.environ.get(_CONFIG_DIR_ENV_VAR, "").strip()
if raw:
expanded = os.path.expandvars(os.path.expanduser(raw))
return Path(expanded).resolve()
return Path.home() / _CONFIG_DIR_NAME


def get_config_dir() -> Path:
"""Get iac-code config directory (~/.iac-code/).
"""Get iac-code config directory.

Creates the directory if it doesn't exist.
Defaults to ``~/.iac-code/``. Can be overridden by the
``IAC_CODE_CONFIG_DIR`` environment variable. The directory is
created if it does not exist; this is read on every call (no
caching).
"""
config_dir = Path.home() / _CONFIG_DIR_NAME
config_dir = _resolve_config_dir()
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir

Expand Down
2 changes: 1 addition & 1 deletion src/iac_code/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def model_ids(self) -> list[str]:
ModelEntry("glm-5"),
ModelEntry("MiniMax-M2.5"),
ModelEntry("kimi-k2.5", support_multimodal=True),
ModelEntry("kimi-k2.6", support_multimodal=True)
ModelEntry("kimi-k2.6", support_multimodal=True),
],
qwenpaw_provider_ids=["aliyun-tokenplan"],
),
Expand Down
2 changes: 2 additions & 0 deletions src/iac_code/services/agent_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class AgentFactoryOptions:
cli_allowed_tools: list[str] | None = None
cli_disallowed_tools: list[str] | None = None
cli_permission_mode: str | None = None
resume_messages: list | None = None


@dataclass
Expand Down Expand Up @@ -162,6 +163,7 @@ def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime:
tool_registry=tool_registry,
session_storage=session_storage,
session_id=session_id,
resume_messages=options.resume_messages,
max_turns=options.max_turns,
cwd=cwd,
permission_context=permission_context,
Expand Down
6 changes: 4 additions & 2 deletions src/iac_code/skills/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path

from iac_code.commands.registry import PromptCommand
from iac_code.config import get_config_dir
from iac_code.skills.loader import load_skill_from_path
from iac_code.skills.skill_definition import SkillDefinition
from iac_code.types.skill_source import SkillSource
Expand All @@ -16,7 +17,8 @@ def discover_all_skills(cwd: str) -> list[SkillDefinition]:

Load order (later entries override earlier with same name):
1. Bundled skills
2. User global skills (~/.iac-code/skills/)
2. User global skills (``<config-dir>/skills/``; defaults to
``~/.iac-code/skills/``, follows ``IAC_CODE_CONFIG_DIR``)
3. Project local skills — skills/ (lower priority)
4. Project local skills — .iac-code/skills/ (higher priority, overrides same name)
"""
Expand All @@ -29,7 +31,7 @@ def discover_all_skills(cwd: str) -> list[SkillDefinition]:
skills[skill.name] = skill

# 2. User global skills
user_skills_dir = Path.home() / ".iac-code" / "skills"
user_skills_dir = get_config_dir() / "skills"
for skill in _scan_skills_dir(user_skills_dir):
skill.source = SkillSource.USER
skills[skill.name] = skill
Expand Down
79 changes: 77 additions & 2 deletions tests/a2a/test_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,31 @@ async def run_streaming(self, prompt: str):


@pytest.mark.asyncio
async def test_executor_rejects_invalid_workspace(tmp_path: Path) -> None:
async def test_executor_creates_missing_workspace(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
runtime = FakeRuntime(agent_loop=FakeAgentLoop([TextDeltaEvent(text="hi")]), session_id="session-1")
monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime)

missing = tmp_path / "missing"
store = A2ATaskStore(metrics=NoOpA2AMetrics())
executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus")
queue = FakeEventQueue()
context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path / "missing")}})
context = FakeRequestContext(metadata={"iac_code": {"cwd": str(missing)}})

await executor.execute(context, queue)

assert missing.is_dir()
final_state = dump(queue.events[-1])["status"]["state"]
assert final_state != "TASK_STATE_FAILED"


@pytest.mark.asyncio
async def test_executor_rejects_workspace_path_pointing_at_file(tmp_path: Path) -> None:
file_path = tmp_path / "not-a-dir"
file_path.write_text("blocker")
store = A2ATaskStore(metrics=NoOpA2AMetrics())
executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus")
queue = FakeEventQueue()
context = FakeRequestContext(metadata={"iac_code": {"cwd": str(file_path)}})

await executor.execute(context, queue)

Expand Down Expand Up @@ -436,6 +456,61 @@ async def run_streaming(self, prompt: str):
assert sorted(prompts) == ["one", "two"]


@pytest.mark.asyncio
async def test_executor_resumes_messages_after_restart(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
from iac_code.a2a.persistence import A2APersistenceStore
from iac_code.agent.message import Message
from iac_code.services.session_storage import SessionStorage

config_dir = tmp_path / "config"
config_dir.mkdir()
monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir))

cwd = tmp_path / "ws"
cwd.mkdir()

seen_resume: list[object | None] = []

def fake_factory(options):
seen_resume.append(options.resume_messages)
return FakeRuntime(
agent_loop=FakeAgentLoop([TextDeltaEvent(text="ok")]),
session_id=options.session_id,
)

monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", fake_factory)

persistence = A2APersistenceStore(tmp_path / "a2a")

store_one = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence)
executor_one = IacCodeA2AExecutor(task_store=store_one, model="qwen3.6-plus")
ctx_one = FakeRequestContext(
task_id="task-1",
context_id="ctx-shared",
text="hi-1",
metadata={"iac_code": {"cwd": str(cwd)}},
)
await executor_one.execute(ctx_one, FakeEventQueue())
session_id = store_one._contexts["ctx-shared"].session_id

SessionStorage().append(str(cwd), session_id, Message(role="user", content="prior turn"))

store_two = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence)
executor_two = IacCodeA2AExecutor(task_store=store_two, model="qwen3.6-plus")
ctx_two = FakeRequestContext(
task_id="task-2",
context_id="ctx-shared",
text="hi-2",
metadata={"iac_code": {"cwd": str(cwd)}},
)
await executor_two.execute(ctx_two, FakeEventQueue())

assert store_two._contexts["ctx-shared"].session_id == session_id
assert seen_resume[0] is None
assert seen_resume[1] is not None
assert any(getattr(m, "content", "") == "prior turn" for m in seen_resume[1])


@pytest.mark.asyncio
async def test_auth_error_is_sanitized(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
def raise_auth_error(options):
Expand Down
32 changes: 32 additions & 0 deletions tests/a2a/test_task_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,38 @@ async def test_task_store_mirrors_task_and_context_to_persistence(tmp_path) -> N
assert persistence.load_task("task-1").context_id == task.context_id


@pytest.mark.asyncio
async def test_get_or_create_context_restores_persisted_session_id(tmp_path) -> None:
from iac_code.a2a.persistence import A2APersistenceStore

persistence = A2APersistenceStore(tmp_path)
store_one = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence)
original = await store_one.get_or_create_context(
context_id="ctx-1", cwd="/tmp", runtime_factory=lambda sid: f"rt-{sid}"
)

store_two = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence)
restored = await store_two.get_or_create_context(
context_id="ctx-1", cwd="/tmp", runtime_factory=lambda sid: f"rt-{sid}"
)

assert restored.session_id == original.session_id
assert restored.runtime == f"rt-{original.session_id}"


@pytest.mark.asyncio
async def test_get_or_create_context_persisted_cwd_mismatch_raises(tmp_path) -> None:
from iac_code.a2a.persistence import A2APersistenceStore

persistence = A2APersistenceStore(tmp_path)
store_one = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence)
await store_one.get_or_create_context(context_id="ctx-1", cwd="/tmp/one", runtime_factory=lambda sid: object())

store_two = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence)
with pytest.raises(ValueError, match="different workspace"):
await store_two.get_or_create_context(context_id="ctx-1", cwd="/tmp/two", runtime_factory=lambda sid: object())


@pytest.mark.asyncio
async def test_task_store_persistence_failure_does_not_abort_task_creation() -> None:
store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=FailingPersistence())
Expand Down
4 changes: 4 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ def _isolate_iac_home(tmp_path_factory, monkeypatch):

A separate tmp dir (not the test's own ``tmp_path``) is used so tests that
treat ``tmp_path`` as "outside $HOME" still behave correctly.

Also unset IAC_CODE_CONFIG_DIR so a developer's local override cannot
leak into tests that rely on the ``Path.home() / ".iac-code"`` fallback.
"""
fake_home = tmp_path_factory.mktemp("iac_home")
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.delenv("IAC_CODE_CONFIG_DIR", raising=False)
22 changes: 22 additions & 0 deletions tests/skills/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,25 @@ def test_glob_pattern_matching(self):
skill = self._make_skill("src-skill", ["src/**/*.py"])
tracker.on_file_accessed("src/core/main.py", [skill])
assert len(tracker.get_activated_skills()) == 1


class TestUserGlobalSkillsRespectConfigDirEnv:
def test_user_global_skills_dir_respects_env(self, monkeypatch, tmp_path):
"""User-global skills are loaded from IAC_CODE_CONFIG_DIR/skills."""
target = tmp_path / "alt-config"
monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(target))

skills_dir = target / "skills"
skills_dir.mkdir(parents=True)
(skills_dir / "alpha.md").write_text("---\ndescription: Alpha\n---\n")

project_cwd = tmp_path / "proj"
project_cwd.mkdir()

from iac_code.skills.discovery import discover_all_skills
from iac_code.types.skill_source import SkillSource

skills = discover_all_skills(str(project_cwd))
alpha = next((s for s in skills if s.name == "alpha"), None)
assert alpha is not None
assert alpha.source == SkillSource.USER
Loading
Loading