Skip to content
2 changes: 1 addition & 1 deletion src/iac_code/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version__ = "0.3.1"
__version__ = "0.4.0"
__release_date__ = ""
163 changes: 135 additions & 28 deletions src/iac_code/acp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@
from iac_code.acp.version import negotiate_version
from iac_code.commands import LocalCommand, create_default_registry
from iac_code.config import DEFAULT_MODEL, get_active_provider_key, load_saved_model
from iac_code.i18n import _
from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime
from iac_code.services.session_index import SessionEntry, SessionIndex
from iac_code.services.session_resolver import ResolutionStatus, resolve_session_argument
from iac_code.services.session_storage import SessionStorage
from iac_code.utils.project_paths import format_resume_command, same_project_path

SESSION_IDLE_TIMEOUT = 3600 # 1 hour
CLEANUP_INTERVAL = 300 # 5 minutes
Expand Down Expand Up @@ -152,7 +156,12 @@ async def new_session(
runtime.session_id,
)
session = ACPSession(
runtime.session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics
runtime.session_id,
runtime.agent_loop,
self.conn,
mcp_configs=mcp_configs,
metrics=self.metrics,
memory_manager=getattr(runtime, "memory_manager", None),
)
self.sessions[session.id] = session
self.metrics.record_session_created()
Expand Down Expand Up @@ -228,25 +237,16 @@ async def list_sessions(
cwd: str | None = None,
**kwargs: Any,
) -> acp.schema.ListSessionsResponse:
from iac_code.utils.project_paths import get_project_dir, get_projects_dir

session_ids: list[str] = []
if cwd:
project_dir = get_project_dir(cwd)
if project_dir.exists():
session_ids = [p.stem for p in project_dir.glob("*.jsonl")]
else:
projects_root = get_projects_dir()
if projects_root.exists():
session_ids = [p.stem for p in projects_root.glob("*/*.jsonl")]
index = SessionIndex()
entries = index.list_for_cwd(cwd) if cwd else index.list_all_projects()
return acp.schema.ListSessionsResponse(
sessions=[
acp.schema.SessionInfo(
session_id=session_id,
cwd=cwd or "",
title=session_id,
session_id=entry.session_id,
cwd=entry.cwd or cwd or "",
title=entry.title,
)
for session_id in session_ids
for entry in entries
],
next_cursor=None,
)
Expand Down Expand Up @@ -297,7 +297,14 @@ async def load_session(
runtime.agent_loop.context_manager.load_messages(history)

# 4. Register session
session = ACPSession(session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics)
session = ACPSession(
session_id,
runtime.agent_loop,
self.conn,
mcp_configs=mcp_configs,
metrics=self.metrics,
memory_manager=getattr(runtime, "memory_manager", None),
)
self.sessions[session_id] = session
self.metrics.record_session_created()
logger.info("Session loaded, session_id=%s, history_messages=%d", session_id, len(history))
Expand Down Expand Up @@ -360,7 +367,12 @@ async def fork_session(

# 4. Register the forked session
session = ACPSession(
new_session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics
new_session_id,
runtime.agent_loop,
self.conn,
mcp_configs=mcp_configs,
metrics=self.metrics,
memory_manager=getattr(runtime, "memory_manager", None),
)
self.sessions[new_session_id] = session
self.metrics.record_session_created()
Expand All @@ -384,28 +396,74 @@ async def resume_session(
mcp_servers: list[MCPServer] | None = None,
**kwargs: Any,
) -> acp.schema.ResumeSessionResponse:
# 1. If session is still active in memory, return directly
if session_id in self.sessions:
# 1. If session is still active in memory by exact id, enforce project ownership before returning.
active_session = self.sessions.get(session_id)
if active_session is not None:
error = _active_session_project_error(cwd, session_id, session_id, active_session)
if error is not None:
raise error
await self._push_available_commands(session_id)
return acp.schema.ResumeSessionResponse()

if self.conn is None:
raise acp.RequestError.internal_error({"error": "ACP client not connected"})

# 2. Try to load persisted history from SessionStorage
resolution = resolve_session_argument(SessionIndex(), cwd, session_id)
if resolution.status == ResolutionStatus.NOT_FOUND:
raise _invalid_params(_("Session not found"), {"session_id": session_id})
if resolution.status == ResolutionStatus.AMBIGUOUS_NAME:
candidate_ids = [entry.session_id for entry in resolution.candidates]
message = _("Session name is ambiguous. Candidates: {candidates}").format(
candidates=", ".join(candidate_ids)
)
raise _invalid_params(
message,
{
"session_id": session_id,
"candidates": [_resume_candidate_data(entry) for entry in resolution.candidates],
},
)

entry = resolution.entry
if entry is None: # pragma: no cover - defensive guard for inconsistent resolver output
raise _invalid_params(_("Session not found"), {"session_id": session_id})

resolved_session_id = entry.session_id
if entry.cwd and not same_project_path(entry.cwd, cwd):
hint = _resume_command(entry.cwd, resolved_session_id)
message = _("Session belongs to another project. Run: {hint}").format(hint=hint)
raise _invalid_params(
message,
{
"session_id": session_id,
"resolved_session_id": resolved_session_id,
"cwd": entry.cwd,
"hint": hint,
},
)

active_session = self.sessions.get(resolved_session_id)
if active_session is not None:
error = _active_session_project_error(cwd, session_id, resolved_session_id, active_session)
if error is not None:
raise error
await self._push_available_commands(resolved_session_id)
return acp.schema.ResumeSessionResponse()

# 2. Try to load persisted history from SessionStorage.
storage = SessionStorage()
if not storage.exists(cwd, session_id):
raise acp.RequestError.invalid_params({"session_id": "Session not found"})
if not storage.exists(cwd, resolved_session_id):
raise _invalid_params(_("Session not found"), {"session_id": session_id})

history = storage.load(cwd, session_id)
history = storage.load(cwd, resolved_session_id)
history = SessionStorage.repair_interrupted(history)

# Convert MCP server configs from ACP protocol types to internal dicts
mcp_configs = _convert_mcp_servers(mcp_servers)

# 3. Rebuild agent runtime with restored history
model = load_saved_model() or DEFAULT_MODEL
runtime = self._create_runtime_with_auth_check(model=model, session_id=session_id, cwd=cwd)
runtime = self._create_runtime_with_auth_check(model=model, session_id=resolved_session_id, cwd=cwd)
replace_bash_with_acp_terminal(
runtime.tool_registry,
self.client_capabilities,
Expand All @@ -418,10 +476,17 @@ async def resume_session(
runtime.agent_loop.context_manager.load_messages(history)

# 4. Register the resumed session
session = ACPSession(session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics)
self.sessions[session_id] = session
session = ACPSession(
resolved_session_id,
runtime.agent_loop,
self.conn,
mcp_configs=mcp_configs,
metrics=self.metrics,
memory_manager=getattr(runtime, "memory_manager", None),
)
self.sessions[resolved_session_id] = session
self.metrics.record_session_created()
await self._push_available_commands(session_id)
await self._push_available_commands(resolved_session_id)

return acp.schema.ResumeSessionResponse()

Expand Down Expand Up @@ -619,6 +684,48 @@ def _convert_mcp_servers(mcp_servers: list[MCPServer] | None) -> list[dict[str,
return configs


def _invalid_params(message: str, data: dict[str, Any] | None = None) -> acp.RequestError:
"""Create an ACP invalid-params error with a useful message."""
return acp.RequestError(-32602, message, data)


def _resume_command(cwd: str, session_id: str) -> str:
return format_resume_command(cwd, session_id)


def _active_session_cwd(session: ACPSession) -> str | None:
cwd = getattr(session.agent_loop, "_cwd", None)
return cwd if isinstance(cwd, str) and cwd else None


def _active_session_project_error(
cwd: str, session_id: str, resolved_session_id: str, session: ACPSession
) -> acp.RequestError | None:
active_cwd = _active_session_cwd(session)
if not active_cwd or same_project_path(active_cwd, cwd):
return None
hint = _resume_command(active_cwd, resolved_session_id)
message = _("Session belongs to another project. Run: {hint}").format(hint=hint)
return _invalid_params(
message,
{
"session_id": session_id,
"resolved_session_id": resolved_session_id,
"cwd": active_cwd,
"hint": hint,
},
)


def _resume_candidate_data(entry: SessionEntry) -> dict[str, str | None]:
return {
"session_id": entry.session_id,
"name": entry.name,
"cwd": entry.cwd,
"command": _resume_command(entry.cwd, entry.session_id),
}


# ---------------------------------------------------------------------------
# Auth methods declaration
# ---------------------------------------------------------------------------
Expand Down
8 changes: 7 additions & 1 deletion src/iac_code/acp/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,11 @@ def __init__(
conn: acp.Client,
mcp_configs: list[dict] | None = None,
metrics: ACPMetrics | None = None,
memory_manager=None,
) -> None:
self.id = session_id
self.agent_loop = agent_loop
self.memory_manager = memory_manager
self._conn = conn
self._current_task: asyncio.Task | None = None
self._replay_task: asyncio.Task[None] | None = None
Expand Down Expand Up @@ -292,7 +294,11 @@ async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse:
prompt_text = acp_blocks_to_prompt_text(prompt)
slash_registry = ACPSlashRegistry()
if slash_registry.is_slash_command(prompt_text):
result = await slash_registry.execute(prompt_text, self.agent_loop)
result = await slash_registry.execute(
prompt_text,
self.agent_loop,
memory_manager=self.memory_manager,
)
await self._conn.session_update(
session_id=self.id,
update=acp.schema.AgentMessageChunk(
Expand Down
43 changes: 41 additions & 2 deletions src/iac_code/acp/slash_registry.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""ACP slash command registry.

Manages commands supported over the ACP protocol.
Only /compact, /clear, and /debug are allowed;
Only /compact, /clear, /debug, /memory, and /rename are allowed;
all other slash commands are rejected with a clear message.
"""

Expand All @@ -10,10 +10,12 @@
import logging

from iac_code.i18n import _
from iac_code.services.session_metadata import normalize_session_name
from iac_code.services.session_storage import SessionStorage

logger = logging.getLogger(__name__)

ACP_SUPPORTED_COMMANDS: frozenset[str] = frozenset({"compact", "clear", "debug"})
ACP_SUPPORTED_COMMANDS: frozenset[str] = frozenset({"compact", "clear", "debug", "memory", "rename"})


class ACPSlashRegistry:
Expand Down Expand Up @@ -51,6 +53,10 @@ async def execute(self, text: str, agent_loop, **context) -> str:
return await self._handle_clear(agent_loop)
if cmd_name == "debug":
return self._handle_debug(args_str)
if cmd_name == "memory":
return self._handle_memory(args_str, context.get("memory_manager"))
if cmd_name == "rename":
return self._handle_rename(args_str, agent_loop)

# Should not reach here
return _("Command '/{cmd_name}' handler not implemented.").format(cmd_name=cmd_name) # pragma: no cover
Expand Down Expand Up @@ -123,3 +129,36 @@ def _handle_debug(self, args: str) -> str:
return _("Debug logging disabled.")

return _("Usage: /debug [on|off]")

def _handle_memory(self, args: str, memory_manager) -> str:
"""View and manage persistent memories."""
if memory_manager is None:
return _("Memory manager is unavailable.")

from iac_code.commands.memory import execute_memory_command

return execute_memory_command(memory_manager, args.split())

def _handle_rename(self, args: str, agent_loop) -> str:
"""Rename the current ACP session non-interactively."""
parts = args.split()
if len(parts) != 1:
return _("Usage: /rename <name>")

cwd = getattr(agent_loop, "_cwd", None)
session_id = getattr(agent_loop, "_session_id", None)
git_branch = getattr(agent_loop, "_current_git_branch", None)
if not isinstance(cwd, str) or not isinstance(session_id, str):
return _("Rename is only available after a session is created.")
if not isinstance(git_branch, str):
git_branch = None

try:
name = normalize_session_name(parts[0])
result = SessionStorage().rename_session(cwd, session_id, name, git_branch=git_branch)
except ValueError as exc:
return str(exc)

if result == "unchanged":
return _("Session is already named {name}").format(name=name)
return _("Renamed session to {name}").format(name=name)
Loading
Loading