diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 21b1b68cba..9e82366e82 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Added daemon-backed user orchestration with agent-to-agent messaging and read-only observation of active sessions. +- Added an orchestration heartbeat skill for compact multi-session progress, blocker, and action summaries. - Added an opt-in auto-refine review hook that can ask whether `/refine` should run after turn intervals or compaction checkpoints. ## [0.2.4] - 2026-07-01 diff --git a/packages/coding-agent/skills/agent-message/SKILL.md b/packages/coding-agent/skills/agent-message/SKILL.md new file mode 100644 index 0000000000..9b79b93876 --- /dev/null +++ b/packages/coding-agent/skills/agent-message/SKILL.md @@ -0,0 +1,38 @@ +--- +name: agent-message +description: Message other active Prime Agent sessions through the daemon. Use to discover active agents and send a direct text message without spoofing sender identity. +--- + +# Agent Message + +Send direct messages to other active Prime Agent sessions through the local +daemon. The daemon derives your sender identity from the current session; do +not try to include a `from` field. + +Call directly from the kernel: + +```python +agents = await agent_message.list_agents() +receipt = await agent_message.send("worker", "Please inspect the latest result.", mode="auto") +``` + +## API + +- `await agent_message.list_agents()` — returns `current` and `agents`, where + each agent includes active session id, session id, optional name, runtime + kind, cwd, streaming state, and pending message count. +- `await agent_message.send(target, message, mode="auto")` — sends one direct + text message to an active session. `target` is resolved by the daemon like + other live-session selectors. `mode` is `"auto"`, `"follow_up"`, or + `"steer"`. Returns a receipt with a `deliveryStatus` field: `"delivered"` + means the message reached an idle target's context; `"queued"` means it was + accepted and will deliver when the target's current work allows (`send` + does not block waiting for that). Delivered receipts carry `deliveredAt`, + queued receipts carry `queuedAt`. + +## Safety + +- Broadcast sends are not supported. +- Sender identity is daemon-derived and cannot be spoofed from Python. +- The daemon enforces message size, rate, and pending-queue limits before + accepting delivery. diff --git a/packages/coding-agent/skills/agent-message/pyproject.toml b/packages/coding-agent/skills/agent-message/pyproject.toml new file mode 100644 index 0000000000..67790f56d9 --- /dev/null +++ b/packages/coding-agent/skills/agent-message/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "agent-message" +version = "0.1.0" +description = "Prime Agent session-to-session messaging skill" +requires-python = ">=3.10" +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/agent_message"] diff --git a/packages/coding-agent/skills/agent-message/src/agent_message/__init__.py b/packages/coding-agent/skills/agent-message/src/agent_message/__init__.py new file mode 100644 index 0000000000..6eb22f2c37 --- /dev/null +++ b/packages/coding-agent/skills/agent-message/src/agent_message/__init__.py @@ -0,0 +1,48 @@ +"""Prime Agent session-to-session messaging skill. + +All routing and sender identity live in the TypeScript daemon. These functions +only call the host bridge exposed inside the Prime Agent IPython kernel. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from rlm import host_request + +MessageMode = Literal["auto", "follow_up", "steer"] + + +async def list_agents() -> dict[str, Any]: + """List active daemon sessions addressable by agent_message.send().""" + return await host_request("agent_message.list") + + +async def send(target: str, message: str, mode: MessageMode = "auto") -> dict[str, Any]: + """Send one direct text message to another active Prime Agent session. + + Args: + target: Active session id, session id/name, or unambiguous suffix. + message: Text payload to deliver. + mode: "auto" queues as follow-up only if the target is streaming; + "follow_up" always uses follow-up when the target is streaming; + "steer" interrupts a streaming target. + + Returns a receipt dict whose "deliveryStatus" is "delivered" (reached an + idle target's context) or "queued" (accepted; delivers when the target's + current work allows -- this call does not block waiting for that). + """ + if not isinstance(target, str): + raise TypeError(f"target must be str, got {type(target).__name__}") + if not isinstance(message, str): + raise TypeError(f"message must be str, got {type(message).__name__}") + if mode not in ("auto", "follow_up", "steer"): + raise ValueError('mode must be "auto", "follow_up", or "steer"') + return await host_request( + "agent_message.send", + { + "target": target, + "message": message, + "mode": mode, + }, + ) diff --git a/packages/coding-agent/skills/agent-observe/SKILL.md b/packages/coding-agent/skills/agent-observe/SKILL.md new file mode 100644 index 0000000000..bee9b89d76 --- /dev/null +++ b/packages/coding-agent/skills/agent-observe/SKILL.md @@ -0,0 +1,39 @@ +--- +name: agent-observe +description: Read-only observation of active Prime Agent sessions through the local daemon. Use to list agents, inspect session status, and read bounded recent-message previews without mutating other sessions. +--- + +# Agent Observe + +Observe active Prime Agent sessions through the local daemon. This skill is +read-only: it can list sessions, inspect one session, and fetch bounded recent +message previews. It cannot prompt, steer, clear, kill, rename, or otherwise +mutate another session. + +Call directly from the kernel: + +```python +agents = await agent_observe.list_agents() +worker = await agent_observe.get_agent("worker") +recent = await agent_observe.recent_messages("worker", limit=6) +``` + +## API + +- `await agent_observe.list_agents()` returns `current` and `agents`. Each + agent includes active session id, session id, optional name, runtime kind, + cwd, status, streaming state, message count, pending count, and a latest + message preview. +- `await agent_observe.get_agent(target)` returns one agent summary. `target` + is resolved like other live-session selectors: active id, session id/name, or + unambiguous suffix. +- `await agent_observe.recent_messages(target, limit=8, max_chars=800)` + returns up to `limit` recent bounded message previews for the target session. + `limit` must be 1-50, and `max_chars` must be 80-2000. + +## Safety + +- This skill is read-only and exposes no mutation commands. +- Message access is bounded by count and per-message character limit. +- Prefer status and recent previews for orchestration. Ask the user before + using observed context to steer or message another session. diff --git a/packages/coding-agent/skills/agent-observe/pyproject.toml b/packages/coding-agent/skills/agent-observe/pyproject.toml new file mode 100644 index 0000000000..32192d16da --- /dev/null +++ b/packages/coding-agent/skills/agent-observe/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "agent-observe" +version = "0.1.0" +description = "Read-only Prime Agent session observation skill" +requires-python = ">=3.10" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/agent_observe"] + +[tool.prime_agent.skill] +import = "agent_observe" diff --git a/packages/coding-agent/skills/agent-observe/src/agent_observe/__init__.py b/packages/coding-agent/skills/agent-observe/src/agent_observe/__init__.py new file mode 100644 index 0000000000..a7ad5552b8 --- /dev/null +++ b/packages/coding-agent/skills/agent-observe/src/agent_observe/__init__.py @@ -0,0 +1,52 @@ +"""Read-only Prime Agent session observation skill. + +All session lookup and data access live in the TypeScript daemon. These +functions only call the host bridge exposed inside the Prime Agent IPython +kernel. +""" + +from __future__ import annotations + +from typing import Any + +from rlm import host_request + + +async def list_agents() -> dict[str, Any]: + """List active daemon sessions visible to this agent.""" + return await host_request("agent_observe.list") + + +async def get_agent(target: str) -> dict[str, Any]: + """Read one active session summary by active id, session id/name, or suffix.""" + if not isinstance(target, str): + raise TypeError(f"target must be str, got {type(target).__name__}") + return await host_request("agent_observe.get", {"target": target}) + + +async def recent_messages( + target: str, + limit: int = 8, + max_chars: int = 800, +) -> dict[str, Any]: + """Read bounded recent message previews from an active session. + + Args: + target: Active session id, session id/name, or unambiguous suffix. + limit: Number of recent messages to return. Host validates 1-50. + max_chars: Per-message preview size. Host validates 80-2000. + """ + if not isinstance(target, str): + raise TypeError(f"target must be str, got {type(target).__name__}") + if not isinstance(limit, int): + raise TypeError(f"limit must be int, got {type(limit).__name__}") + if not isinstance(max_chars, int): + raise TypeError(f"max_chars must be int, got {type(max_chars).__name__}") + return await host_request( + "agent_observe.recent", + { + "target": target, + "limit": limit, + "max_chars": max_chars, + }, + ) diff --git a/packages/coding-agent/skills/orchestration-heartbeat/SKILL.md b/packages/coding-agent/skills/orchestration-heartbeat/SKILL.md new file mode 100644 index 0000000000..41b3846b63 --- /dev/null +++ b/packages/coding-agent/skills/orchestration-heartbeat/SKILL.md @@ -0,0 +1,41 @@ +--- +name: orchestration-heartbeat +description: Initialize or refresh an internal orchestrator heartbeat that observes active Prime Agent sessions, summarizes progress and blockers, and recommends next actions or messages. +--- + +# Orchestration Heartbeat + +Use this skill from an orchestrator session to create or refresh a recurring +internal heartbeat for supervising multiple active Prime Agent sessions. + +The heartbeat is an RLM heartbeat, not the user's visible `/heartbeat`. It +cannot read, replace, pause, resume, or clear the user-level heartbeat. + +Call directly from IPython: + +```python +await orchestration_heartbeat.initialize() +await orchestration_heartbeat.initialize(interval="10m", focus="EmulatorBench and AutoEnv") +``` + +## API + +- `await orchestration_heartbeat.initialize(interval="5m", label="orchestrator", + focus=None, require_user_approval=True)` creates or updates the labeled + orchestrator heartbeat for the current session. +- `await orchestration_heartbeat.ensure(...)` is an alias for `initialize`. +- `orchestration_heartbeat.build_instruction(...)` returns the prompt text + without creating or updating a heartbeat. + +## Heartbeat Behavior + +Each recurring orchestration heartbeat should: + +- Inspect active sessions with `agent_observe`. +- Summarize each relevant session as `active`, `waiting`, `blocked`, `error`, + or `completed`. +- Include current progress and explicit blockers. +- Recommend the next action and, when useful, draft a target message. +- Ask for user approval before sending cross-session messages unless the user + has already approved that specific messaging policy. +- Keep the update compact and operational instead of dumping logs. diff --git a/packages/coding-agent/skills/orchestration-heartbeat/pyproject.toml b/packages/coding-agent/skills/orchestration-heartbeat/pyproject.toml new file mode 100644 index 0000000000..78d516c462 --- /dev/null +++ b/packages/coding-agent/skills/orchestration-heartbeat/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "orchestration-heartbeat" +version = "0.1.0" +description = "Prime Agent orchestration heartbeat skill" +requires-python = ">=3.10" +dependencies = ["agent-observe", "rlm-heartbeat"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/orchestration_heartbeat"] diff --git a/packages/coding-agent/skills/orchestration-heartbeat/src/orchestration_heartbeat/__init__.py b/packages/coding-agent/skills/orchestration-heartbeat/src/orchestration_heartbeat/__init__.py new file mode 100644 index 0000000000..5bf6929fed --- /dev/null +++ b/packages/coding-agent/skills/orchestration-heartbeat/src/orchestration_heartbeat/__init__.py @@ -0,0 +1,197 @@ +"""Prime Agent orchestration heartbeat skill. + +This skill composes the bundled agent_observe and rlm_heartbeat skills. It +creates or refreshes one internal heartbeat for the current orchestrator +session; it never touches the user's visible /heartbeat. +""" + +from __future__ import annotations + +from typing import Any + +import agent_observe +import rlm_heartbeat + +DEFAULT_INTERVAL = "5m" +DEFAULT_LABEL = "orchestrator" + + +def _first_value(agent: dict[str, Any], *keys: str) -> Any: + for key in keys: + value = agent.get(key) + if value is not None: + return value + return None + + +def _session_label(agent: dict[str, Any]) -> str: + name = _first_value(agent, "sessionName", "name") + session_id = _first_value(agent, "sessionId", "session_id") + active_session_id = _first_value(agent, "activeSessionId", "active_session_id") + cwd = agent.get("cwd") + status = agent.get("status") + streaming = _first_value(agent, "isStreaming", "streaming") + pending = _first_value(agent, "pendingMessageCount", "pending_message_count") + parts = [ + f"name={name}" if name else None, + f"session_id={session_id}" if session_id else None, + f"active_session_id={active_session_id}" if active_session_id else None, + f"cwd={cwd}" if cwd else None, + f"status={status}" if status else None, + f"streaming={streaming}" if streaming is not None else None, + f"pending_messages={pending}" if pending is not None else None, + ] + return "- " + ", ".join(part for part in parts if part) + + +def _format_sessions(agents: list[dict[str, Any]]) -> str: + if not agents: + return "- No other active sessions were visible when this heartbeat was initialized." + return "\n".join(_session_label(agent) for agent in agents) + + +def _same_session(left: dict[str, Any], right: dict[str, Any]) -> bool: + for keys in ( + ("activeSessionId", "active_session_id"), + ("sessionId", "session_id"), + ): + left_value = _first_value(left, *keys) + right_value = _first_value(right, *keys) + if left_value is not None and right_value is not None and left_value == right_value: + return True + return False + + +def _other_agents(roster: dict[str, Any]) -> list[dict[str, Any]]: + agents = roster.get("agents", []) + if not isinstance(agents, list): + return [] + current = roster.get("current") + if not isinstance(current, dict): + return agents + return [agent for agent in agents if not _same_session(agent, current)] + + +def _normalize_interval_expression(value: str) -> str: + normalized = " ".join(value.strip().lower().split()) + if normalized.startswith("every "): + return normalized[6:].strip() + return normalized + + +def _interval_matches_schedule(interval: str, schedule: Any) -> bool: + if not isinstance(schedule, dict): + return False + expression = schedule.get("expression") + if not isinstance(expression, str): + return False + return _normalize_interval_expression(expression) == _normalize_interval_expression(interval) + + +def build_instruction( + sessions: list[dict[str, Any]] | None = None, + focus: str | None = None, + require_user_approval: bool = True, +) -> str: + """Build the recurring orchestration heartbeat instruction.""" + if sessions is not None and not isinstance(sessions, list): + raise TypeError(f"sessions must be list or None, got {type(sessions).__name__}") + if focus is not None and not isinstance(focus, str): + raise TypeError(f"focus must be str or None, got {type(focus).__name__}") + if not isinstance(require_user_approval, bool): + raise TypeError( + f"require_user_approval must be bool, got {type(require_user_approval).__name__}" + ) + + session_block = _format_sessions(sessions or []) + focus_line = f"\nCurrent focus: {focus.strip()}\n" if focus and focus.strip() else "" + approval_rule = ( + "Do not send cross-session messages until the user approves the specific target and message, " + "unless the user has already granted an explicit messaging policy for this run." + if require_user_approval + else "You may send cross-session messages when they are clearly necessary and within the user's policy." + ) + + return f"""Orchestration heartbeat. + +Use agent_observe to inspect active Prime Agent sessions. For each relevant session, summarize: +- state: active, waiting, blocked, error, or completed +- current progress +- explicit blockers, separating task blockers from auth/tooling/infrastructure blockers +- recommended next action + +If a session needs intervention, recommend the exact action and draft the target message. {approval_rule} + +Keep the update compact and operational. Prefer session-by-session status over log detail. If a session's status is ambiguous, say what evidence is missing and what to inspect next. +{focus_line} +Sessions visible when this heartbeat was initialized or refreshed: +{session_block} + +Before finalizing the heartbeat update, check whether new active sessions appeared since initialization and include them in the summary.""" + + +async def initialize( + interval: str = DEFAULT_INTERVAL, + label: str = DEFAULT_LABEL, + focus: str | None = None, + require_user_approval: bool = True, +) -> dict[str, Any]: + """Create or update this session's internal orchestrator heartbeat.""" + if not isinstance(interval, str): + raise TypeError(f"interval must be str, got {type(interval).__name__}") + if not isinstance(label, str): + raise TypeError(f"label must be str, got {type(label).__name__}") + + roster = await agent_observe.list_agents() + agents = _other_agents(roster) + instruction = build_instruction( + sessions=agents, + focus=focus, + require_user_approval=require_user_approval, + ) + existing = await rlm_heartbeat.list(include_inactive=False) + for heartbeat in existing.get("heartbeats", []): + if heartbeat.get("label") == label: + update_args: dict[str, Any] = { + "instruction": instruction, + "label": label, + } + if heartbeat.get("status") == "paused": + update_args["status"] = "resume" + if not _interval_matches_schedule(interval, heartbeat.get("schedule")): + update_args["interval"] = interval + updated = await rlm_heartbeat.update(heartbeat["id"], **update_args) + updated_heartbeat = updated.get("heartbeat") + if updated_heartbeat is None: + raise RuntimeError( + f"RLM heartbeat {heartbeat['id']} disappeared before it could be updated" + ) + return { + "action": "updated", + "heartbeat": updated_heartbeat, + "sessions": agents, + "instruction": instruction, + } + + created = await rlm_heartbeat.create(instruction, interval=interval, label=label) + return { + "action": "created", + "heartbeat": created.get("heartbeat"), + "sessions": agents, + "instruction": instruction, + } + + +async def ensure( + interval: str = DEFAULT_INTERVAL, + label: str = DEFAULT_LABEL, + focus: str | None = None, + require_user_approval: bool = True, +) -> dict[str, Any]: + """Alias for initialize().""" + return await initialize( + interval=interval, + label=label, + focus=focus, + require_user_approval=require_user_approval, + ) diff --git a/packages/coding-agent/src/cli/daemon-command.ts b/packages/coding-agent/src/cli/daemon-command.ts index ae8ed4a006..1fcb480a19 100644 --- a/packages/coding-agent/src/cli/daemon-command.ts +++ b/packages/coding-agent/src/cli/daemon-command.ts @@ -35,6 +35,8 @@ const DAEMON_CLIENT_COMMANDS = new Set([ "kill", "rename", "prompt", + "send", + "agent-messages", "steer", "follow-up", "state", @@ -100,7 +102,8 @@ function parseDaemonClientCommand(args: string[]): ParsedDaemonClientCommand { continue; } - if (arg === "--" && command === "cron") { + // send/cron parse "--" themselves as an end-of-flags separator + if (arg === "--" && (command === "cron" || command === "send")) { positionals.push(arg); passthrough = true; continue; @@ -206,6 +209,12 @@ async function runDaemonClientCommand(parsed: ParsedDaemonClientCommand): Promis case "prompt": await runPrompt(client, parsed.positionals); return; + case "send": + await runSend(client, parsed.positionals, parsed.json); + return; + case "agent-messages": + await runAgentMessages(client, parsed.positionals, parsed.json); + return; case "steer": await runMessageCommand(client, "steer", parsed.positionals, parsed.json); return; @@ -804,6 +813,146 @@ async function runPrompt(client: DaemonClient, args: string[]): Promise { } } +async function runAgentMessages(client: DaemonClient, args: string[], json: boolean): Promise { + const subcommand = args[0]; + switch (subcommand) { + case "status": + requireNoExtraArgs(args, "daemon agent-messages status"); + await printResponseData(client, { type: "agent_messages_status" }, json); + return; + case "pause": + requireNoExtraArgs(args, "daemon agent-messages pause"); + await printResponseData(client, { type: "agent_messages_pause" }, json); + return; + case "resume": + requireNoExtraArgs(args, "daemon agent-messages resume"); + await printResponseData(client, { type: "agent_messages_resume" }, json); + return; + case "clear": { + const activeSessionId = args[1]; + if (!activeSessionId || args.length !== 2) { + throw new Error("Usage: daemon agent-messages clear "); + } + await printResponseData(client, { type: "agent_messages_clear", activeSessionId }, json); + return; + } + default: + throw new Error("Usage: daemon agent-messages "); + } +} + +function requireNoExtraArgs(args: string[], usage: string): void { + if (args.length > 1) { + throw new Error(`Usage: ${usage}`); + } +} + +async function runSend(client: DaemonClient, args: string[], json: boolean): Promise { + const parsed = parseSendArgs(args); + const response = await client.request({ + type: "send_message", + targetActiveSessionId: parsed.targetActiveSessionId, + fromActiveSessionId: parsed.fromActiveSessionId, + deliveryMode: parsed.deliveryMode, + message: parsed.message, + }); + const data = requireSuccess(response); + if (json) { + printJson(data); + return; + } + if (isAgentMessageReceipt(data)) { + const target = data.target.sessionName ?? data.target.activeSessionId; + console.log(data.deliveryStatus === "queued" ? `Queued for ${target}` : `Sent to ${target}`); + return; + } + console.log("ok"); +} + +interface ParsedSendArgs { + targetActiveSessionId: string; + fromActiveSessionId?: string; + deliveryMode?: "auto" | "steer" | "follow_up"; + message: string; +} + +function parseSendArgs(args: string[]): ParsedSendArgs { + let fromActiveSessionId: string | undefined; + let deliveryMode: "auto" | "steer" | "follow_up" | undefined; + let targetActiveSessionId: string | undefined; + let explicitMessage: string | undefined; + const messageParts: string[] = []; + let parseOptions = true; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (parseOptions && arg === "--") { + parseOptions = false; + continue; + } + if (parseOptions && arg === "--from") { + const value = args[index + 1]; + if (!value) { + throw new Error("--from requires a session id or name"); + } + fromActiveSessionId = value; + index++; + continue; + } + if (parseOptions && arg === "--steer") { + deliveryMode = "steer"; + continue; + } + if (parseOptions && arg === "--follow-up") { + deliveryMode = "follow_up"; + continue; + } + if (parseOptions && arg === "--auto") { + deliveryMode = "auto"; + continue; + } + if (parseOptions && arg === "--message") { + if (!targetActiveSessionId) { + throw new Error("--message must appear after the target session"); + } + const value = args[index + 1]; + if (!value) { + throw new Error("--message requires message text"); + } + explicitMessage = value; + index++; + parseOptions = false; + continue; + } + if (parseOptions && arg.startsWith("--")) { + throw new Error(`Unknown option for daemon send: ${arg} (use -- before message text starting with --)`); + } + if (!targetActiveSessionId) { + targetActiveSessionId = arg; + continue; + } + messageParts.push(arg); + } + + if (explicitMessage !== undefined && messageParts.length > 0) { + throw new Error( + "Usage: daemon send [--from ] [--steer|--follow-up] [--message |]", + ); + } + const message = (explicitMessage ?? messageParts.join(" ")).trim(); + if (!targetActiveSessionId || !message) { + throw new Error( + "Usage: daemon send [--from ] [--steer|--follow-up] [--message |]", + ); + } + return { + targetActiveSessionId, + fromActiveSessionId, + deliveryMode, + message, + }; +} + async function runMessageCommand( client: DaemonClient, type: "steer" | "follow_up", @@ -1450,6 +1599,20 @@ function getCronJob(value: unknown): { id: string; nextRunAt?: string } | undefi return { id: candidate.id, ...(typeof candidate.nextRunAt === "string" ? { nextRunAt: candidate.nextRunAt } : {}) }; } +function isAgentMessageReceipt( + value: unknown, +): value is { target: { activeSessionId: string; sessionName?: string }; deliveryStatus?: string } { + if (!value || typeof value !== "object") { + return false; + } + const target = (value as { target?: unknown }).target; + return ( + !!target && + typeof target === "object" && + typeof (target as { activeSessionId?: unknown }).activeSessionId === "string" + ); +} + function printDaemonHelp(): void { console.log(`${chalk.bold("Usage:")} ${APP_NAME} daemon [options] [session name] @@ -1466,6 +1629,8 @@ ${chalk.bold("Commands:")} attach Attach an interactive terminal to a live session detach [session] Detach this client from one session or all sessions prompt Send a prompt, stream events, and exit when idle + send [options] Send an agent-to-agent message to another live session + agent-messages Safety controls: status, pause, resume, clear steer Queue a steering message follow-up Queue a follow-up message rename Rename a live session @@ -1486,6 +1651,8 @@ ${chalk.bold("Options:")} --cwd Working directory for the created session --foreground, --no-detach Keep daemon attached to this terminal for debugging --json Print raw JSON for commands with formatted output; attach streams raw protocol JSON + send options: --from , --steer, --follow-up, --message + agent-messages clear only clears one explicitly named session Agent options such as --model, --provider, --tools, and --thinking apply to created sessions. ${chalk.bold("Examples:")} @@ -1504,6 +1671,7 @@ ${chalk.bold("Examples:")} ${APP_NAME} daemon --socket /tmp/prime-agent.sock cron add "*/30 * * * *" -- "Check progress" ${APP_NAME} daemon --socket /tmp/prime-agent.sock cron list ${APP_NAME} daemon --socket /tmp/prime-agent.sock prompt "Say hello" + ${APP_NAME} daemon --socket /tmp/prime-agent.sock send --from planner worker --message "Use this context..." ${APP_NAME} daemon --socket /tmp/prime-agent.sock attach ${APP_NAME} daemon --socket /tmp/prime-agent.sock shutdown ${APP_NAME} daemon shutdown --all diff --git a/packages/coding-agent/src/core/agent-messages.ts b/packages/coding-agent/src/core/agent-messages.ts new file mode 100644 index 0000000000..85f6aa01ae --- /dev/null +++ b/packages/coding-agent/src/core/agent-messages.ts @@ -0,0 +1,300 @@ +import { randomUUID } from "node:crypto"; +import type { HostRequestHandler } from "./kernel/index.js"; + +export const AGENT_MESSAGE_SKILL_NAME = "agent-message"; +export const AGENT_MESSAGE_IMPORT_NAME = "agent_message"; +export const AGENT_MESSAGE_SOURCE = "agent_message"; +export const DEFAULT_AGENT_MESSAGE_MAX_CHARS = 16_384; +export const DEFAULT_AGENT_MESSAGE_MAX_PENDING_PER_SESSION = 20; +export const DEFAULT_AGENT_MESSAGE_RATE_LIMIT_CAPACITY = 3; +export const DEFAULT_AGENT_MESSAGE_RATE_LIMIT_REFILL_MS = 1000; + +export type AgentSessionMessageDeliveryMode = "auto" | "steer" | "follow_up"; +export type AgentSessionMessageDeliveryStatus = "delivered" | "queued"; +export type AgentSessionMessageRuntimeKind = "top-level" | "subagent"; + +export interface AgentSessionMessageEndpoint { + activeSessionId: string; + sessionId: string; + sessionName?: string; + runtimeKind?: AgentSessionMessageRuntimeKind; +} + +export interface AgentSessionMessageSender extends Partial { + clientId?: string; +} + +export interface AgentSessionMessageAgentSummary extends AgentSessionMessageEndpoint { + cwd: string; + isStreaming: boolean; + pendingMessageCount: number; + parentActiveSessionId?: string; + rlmChildId?: string; +} + +export interface AgentSessionMessageListResult { + current?: AgentSessionMessageEndpoint; + agents: AgentSessionMessageAgentSummary[]; +} + +export interface AgentSessionMessagePayload { + id: string; + source: typeof AGENT_MESSAGE_SOURCE; + message: string; + from?: AgentSessionMessageSender; + target: AgentSessionMessageEndpoint; + deliveryMode: AgentSessionMessageDeliveryMode; +} + +export interface AgentSessionMessageReceipt { + id: string; + source: typeof AGENT_MESSAGE_SOURCE; + target: AgentSessionMessageEndpoint; + from?: AgentSessionMessageSender; + message: string; + // Not named "status": the kernel host bridge envelope reserves that key. + deliveryStatus: AgentSessionMessageDeliveryStatus; + /** Present when deliveryStatus is "delivered": the message reached the target's context. */ + deliveredAt?: string; + /** Present when deliveryStatus is "queued": the message waits behind the target's current work. */ + queuedAt?: string; + deliveryMode: AgentSessionMessageDeliveryMode; +} + +export interface AgentSessionMessageSendInput { + target: string; + message: string; + deliveryMode?: AgentSessionMessageDeliveryMode; +} + +export interface AgentSessionMessageController { + listAgents(): AgentSessionMessageListResult; + sendAgentMessage(input: AgentSessionMessageSendInput): Promise; +} + +export interface AgentSessionMessageSafetyStatus { + paused: boolean; + maxMessageChars: number; + maxPendingPerSession: number; + rateLimitCapacity: number; + rateLimitRefillMs: number; +} + +export function createAgentSessionMessageId(): string { + return `agentmsg_${randomUUID()}`; +} + +export function normalizeAgentSessionMessage(message: string, maxChars = DEFAULT_AGENT_MESSAGE_MAX_CHARS): string { + const trimmed = message.trim(); + if (!trimmed) { + throw new Error("Agent session message cannot be empty"); + } + if (trimmed.length > maxChars) { + throw new Error(`Agent session message is too long: ${trimmed.length} chars exceeds ${maxChars}`); + } + return trimmed; +} + +export function normalizeAgentSessionMessageDeliveryMode(value: unknown): AgentSessionMessageDeliveryMode | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (value === "auto" || value === "steer" || value === "follow_up") { + return value; + } + throw new Error('agent_message.send mode must be "auto", "steer", or "follow_up"'); +} + +export function assertDirectAgentMessageTarget(target: string): string { + const normalized = target.trim(); + if (!normalized) { + throw new Error("Agent message target cannot be empty"); + } + if (normalized === "*" || normalized.toLowerCase() === "all" || normalized.toLowerCase() === "broadcast") { + throw new Error("Broadcast agent messaging is not supported"); + } + return normalized; +} + +export function assertAgentMessageQueueCapacity( + pendingMessageCount: number, + maxPending = DEFAULT_AGENT_MESSAGE_MAX_PENDING_PER_SESSION, +): void { + if (pendingMessageCount >= maxPending) { + throw new Error( + `Target session has too many pending messages: ${pendingMessageCount} pending, limit is ${maxPending}`, + ); + } +} + +export function resolveAgentSessionMessageStreamingBehavior( + isTargetStreaming: boolean, + deliveryMode: AgentSessionMessageDeliveryMode | undefined, +): "steer" | "followUp" | undefined { + const mode = deliveryMode ?? "auto"; + if (!isTargetStreaming) { + return undefined; + } + if (mode === "steer") { + return "steer"; + } + return "followUp"; +} + +export function parseAgentSessionMessagePromptId(text: string): string | undefined { + const lines = text.split("\n"); + if (lines[0] !== "Agent-to-agent message received." || lines[1] !== `Source: ${AGENT_MESSAGE_SOURCE}`) { + return undefined; + } + const toLineIndex = lines[2]?.startsWith("From: ") ? 3 : 2; + if (!lines[toLineIndex]?.startsWith("To: ")) { + return undefined; + } + const match = /^Message id: (agentmsg_[^\n]+)$/.exec(lines[toLineIndex + 1] ?? ""); + return match?.[1]; +} + +export function isAgentSessionMessagePrompt(text: string): boolean { + return parseAgentSessionMessagePromptId(text) !== undefined; +} + +export function createAgentSessionMessagePrompt(payload: AgentSessionMessagePayload): string { + const lines = ["Agent-to-agent message received.", `Source: ${payload.source}`]; + if (payload.from) { + lines.push(`From: ${formatAgentSessionMessageSender(payload.from)}`); + } + lines.push(`To: ${formatAgentSessionMessageEndpoint(payload.target)}`); + lines.push(`Message id: ${payload.id}`); + lines.push(""); + lines.push(payload.message); + return lines.join("\n"); +} + +export function createAgentSessionMessageReceipt( + payload: AgentSessionMessagePayload, + status: AgentSessionMessageDeliveryStatus, + at = new Date().toISOString(), +): AgentSessionMessageReceipt { + return { + id: payload.id, + source: payload.source, + target: payload.target, + from: payload.from, + message: payload.message, + deliveryStatus: status, + ...(status === "delivered" ? { deliveredAt: at } : { queuedAt: at }), + deliveryMode: payload.deliveryMode, + }; +} + +export interface AgentSessionMessageRateLimiterOptions { + capacity?: number; + refillMs?: number; + now?: () => number; +} + +export class AgentSessionMessageRateLimiter { + private readonly capacity: number; + private readonly refillMs: number; + private readonly now: () => number; + private readonly buckets = new Map(); + + constructor(options: AgentSessionMessageRateLimiterOptions = {}) { + this.capacity = options.capacity ?? DEFAULT_AGENT_MESSAGE_RATE_LIMIT_CAPACITY; + this.refillMs = options.refillMs ?? DEFAULT_AGENT_MESSAGE_RATE_LIMIT_REFILL_MS; + this.now = options.now ?? (() => Date.now()); + } + + tryConsume(key: string): { ok: true } | { ok: false; retryAfterMs: number } { + const now = this.now(); + const bucket = this.buckets.get(key) ?? { tokens: this.capacity, updatedAt: now }; + const elapsed = Math.max(0, now - bucket.updatedAt); + const refilledTokens = Math.floor(elapsed / this.refillMs); + if (refilledTokens > 0) { + bucket.tokens = Math.min(this.capacity, bucket.tokens + refilledTokens); + bucket.updatedAt += refilledTokens * this.refillMs; + } + if (bucket.tokens <= 0) { + this.buckets.set(key, bucket); + return { ok: false, retryAfterMs: Math.max(1, bucket.updatedAt + this.refillMs - now) }; + } + bucket.tokens -= 1; + this.buckets.set(key, bucket); + return { ok: true }; + } + + refund(key: string): void { + const bucket = this.buckets.get(key); + if (!bucket) { + return; + } + bucket.tokens = Math.min(this.capacity, bucket.tokens + 1); + this.buckets.set(key, bucket); + } + + clear(key?: string): void { + if (key) { + this.buckets.delete(key); + return; + } + this.buckets.clear(); + } + + clearMatching(predicate: (key: string) => boolean): void { + for (const key of this.buckets.keys()) { + if (predicate(key)) { + this.buckets.delete(key); + } + } + } +} + +export function createAgentMessageHostHandlers( + controller: AgentSessionMessageController, +): Record { + return { + "agent_message.list": async () => controller.listAgents() as unknown as Record, + "agent_message.send": async (payload) => { + if (typeof payload.target !== "string") { + throw new Error("agent_message.send target must be a string"); + } + if (typeof payload.message !== "string") { + throw new Error("agent_message.send message must be a string"); + } + return (await controller.sendAgentMessage({ + target: payload.target, + message: payload.message, + deliveryMode: normalizeAgentSessionMessageDeliveryMode(payload.mode), + })) as unknown as Record; + }, + }; +} + +function formatAgentSessionMessageMetadata(value: string): string { + return value.replace(/[\s,]+/g, " ").trim(); +} + +function formatAgentSessionMessageSender(sender: AgentSessionMessageSender): string { + const parts: string[] = []; + if (sender.sessionName) { + const sessionName = formatAgentSessionMessageMetadata(sender.sessionName); + if (sessionName) { + parts.push(sessionName); + } + } + if (sender.activeSessionId) { + parts.push(`active ${formatAgentSessionMessageMetadata(sender.activeSessionId)}`); + } + if (sender.sessionId) { + parts.push(`session ${formatAgentSessionMessageMetadata(sender.sessionId)}`); + } + if (sender.clientId) { + parts.push(`client ${formatAgentSessionMessageMetadata(sender.clientId)}`); + } + return parts.length > 0 ? parts.join(", ") : "unknown sender"; +} + +function formatAgentSessionMessageEndpoint(endpoint: AgentSessionMessageEndpoint): string { + const name = endpoint.sessionName ? `${formatAgentSessionMessageMetadata(endpoint.sessionName)}, ` : ""; + return `${name}active ${formatAgentSessionMessageMetadata(endpoint.activeSessionId)}, session ${formatAgentSessionMessageMetadata(endpoint.sessionId)}`; +} diff --git a/packages/coding-agent/src/core/agent-observe.ts b/packages/coding-agent/src/core/agent-observe.ts new file mode 100644 index 0000000000..4be28b2ad9 --- /dev/null +++ b/packages/coding-agent/src/core/agent-observe.ts @@ -0,0 +1,197 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; + +export const AGENT_OBSERVE_SKILL_NAME = "agent-observe"; +export const AGENT_OBSERVE_IMPORT_NAME = "agent_observe"; +export const ORCHESTRATION_HEARTBEAT_SKILL_NAME = "orchestration-heartbeat"; + +export interface AgentObserveAgentSummary { + activeSessionId: string; + sessionId: string; + sessionName?: string; + runtimeKind?: "top-level" | "subagent"; + cwd: string; + status: string; + isCurrent: boolean; + isStreaming: boolean; + isCompacting: boolean; + attachedClients: number; + messageCount: number; + pendingMessageCount: number; + parentActiveSessionId?: string; + parentSessionId?: string; + rlmChildId?: string; + rlmParentNodeId?: string; + firstMessage?: string; + latestMessage?: AgentObserveMessagePreview; +} + +export interface AgentObserveListResult { + current: AgentObserveAgentSummary; + agents: AgentObserveAgentSummary[]; +} + +export interface AgentObserveAgentSnapshot { + agent: AgentObserveAgentSummary; +} + +export interface AgentObserveRecentMessagesInput { + target: string; + limit?: number; + maxChars?: number; +} + +export interface AgentObserveRecentMessagesResult { + agent: AgentObserveAgentSummary; + messages: AgentObserveMessagePreview[]; + limit: number; + maxChars: number; + truncated: boolean; +} + +export interface AgentObserveMessagePreview { + index: number; + role: string; + timestamp?: number; + text: string; + truncated: boolean; + toolCalls?: string[]; + customType?: string; +} + +export interface AgentObserveController { + listAgents(): AgentObserveListResult; + getAgent(target: string): AgentObserveAgentSnapshot; + recentMessages(input: AgentObserveRecentMessagesInput): AgentObserveRecentMessagesResult; +} + +export function createAgentObserveHostHandlers(controller: AgentObserveController) { + return { + "agent_observe.list": async () => controller.listAgents() as unknown as Record, + "agent_observe.get": async (payload: Record = {}) => { + if (typeof payload.target !== "string") { + throw new Error("agent_observe.get target must be a string"); + } + return controller.getAgent(payload.target) as unknown as Record; + }, + "agent_observe.recent": async (payload: Record = {}) => { + if (typeof payload.target !== "string") { + throw new Error("agent_observe.recent target must be a string"); + } + return controller.recentMessages({ + target: payload.target, + limit: normalizeOptionalInteger(payload.limit, "agent_observe.recent limit"), + maxChars: normalizeOptionalInteger(payload.max_chars ?? payload.maxChars, "agent_observe.recent max_chars"), + }) as unknown as Record; + }, + }; +} + +export function normalizeObserveLimit(limit: number | undefined, defaultLimit = 8): number { + return clampInteger(limit ?? defaultLimit, 1, 50, "agent_observe limit"); +} + +export function normalizeObserveMaxChars(maxChars: number | undefined, defaultMaxChars = 800): number { + return clampInteger(maxChars ?? defaultMaxChars, 80, 2_000, "agent_observe max_chars"); +} + +export function createAgentObserveMessagePreview( + message: AgentMessage, + index: number, + maxChars: number, +): AgentObserveMessagePreview { + const text = messageText(message); + const clipped = truncate(text, maxChars); + const toolCalls = message.role === "assistant" ? assistantToolCalls(message) : undefined; + return { + index, + role: message.role, + ...(message.timestamp ? { timestamp: message.timestamp } : {}), + text: clipped.text, + truncated: clipped.truncated, + ...(toolCalls && toolCalls.length > 0 ? { toolCalls } : {}), + ...(message.role === "custom" ? { customType: message.customType } : {}), + }; +} + +function normalizeOptionalInteger(value: unknown, label: string): number | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new Error(`${label} must be an integer when provided`); + } + return value; +} + +function clampInteger(value: number, min: number, max: number, label: string): number { + if (!Number.isInteger(value)) { + throw new Error(`${label} must be an integer`); + } + if (value < min || value > max) { + throw new Error(`${label} must be between ${min} and ${max}`); + } + return value; +} + +function truncate(text: string, maxChars: number): { text: string; truncated: boolean } { + if (text.length <= maxChars) { + return { text, truncated: false }; + } + return { text: text.slice(0, maxChars), truncated: true }; +} + +function messageText(message: AgentMessage): string { + switch (message.role) { + case "user": + case "assistant": + return contentText(message.content); + case "toolResult": + return contentText(message.content); + case "bashExecution": + return [message.command, message.output].filter(Boolean).join("\n"); + case "custom": + return typeof message.content === "string" ? message.content : contentText(message.content); + case "branchSummary": + return message.summary; + case "compactionSummary": + return message.summary; + default: { + const exhaustive: never = message; + return JSON.stringify(exhaustive); + } + } +} + +function contentText(content: unknown): string { + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return ""; + } + return content + .map((block) => { + if (!block || typeof block !== "object" || !("type" in block)) { + return ""; + } + if (block.type === "text" && "text" in block && typeof block.text === "string") { + return block.text; + } + if (block.type === "thinking" && "thinking" in block && typeof block.thinking === "string") { + return block.thinking; + } + if (block.type === "image") { + return "[image]"; + } + if (block.type === "toolCall" && "name" in block && typeof block.name === "string") { + return `[tool_call:${block.name}]`; + } + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function assistantToolCalls(message: Extract): string[] { + return message.content.filter((block) => block.type === "toolCall").map((block) => block.name); +} diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 294206219a..701de2e81d 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -2,6 +2,8 @@ import { join } from "node:path"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Model } from "@earendil-works/pi-ai"; import { getAgentDir } from "../config.js"; +import type { AgentSessionMessageController } from "./agent-messages.js"; +import type { AgentObserveController } from "./agent-observe.js"; import { installAgentTraceUpload } from "./agent-traces.js"; import { AuthStorage } from "./auth-storage.js"; import type { AgentRlmHeartbeatController } from "./cron-jobs.js"; @@ -53,6 +55,8 @@ export interface AgentSessionCreationOptions { initialActiveToolNames?: string[]; allowedToolNames?: string[]; includeGoals?: boolean; + agentMessageController?: AgentSessionMessageController; + agentObserveController?: AgentObserveController; rlmDepth?: number; rlmMaxDepth?: number; rlmSessionDir?: string; @@ -231,6 +235,8 @@ export async function createAgentSessionFromServices( initialActiveToolNames: options.initialActiveToolNames, allowedToolNames: options.allowedToolNames, includeGoals: options.includeGoals, + agentMessageController: options.agentMessageController, + agentObserveController: options.agentObserveController, rlmDepth: options.rlmDepth, rlmMaxDepth: options.rlmMaxDepth, rlmSessionDir: options.rlmSessionDir, diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c3884f201a..2ebb6b444e 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -28,7 +28,7 @@ import { type ShouldStopAfterTurnContext, type ThinkingLevel, } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, ImageContent, Message, Model, TextContent, Usage } from "@earendil-works/pi-ai"; +import type { AssistantMessage, ImageContent, Model, TextContent, Usage } from "@earendil-works/pi-ai"; import { clampThinkingLevel, cleanupSessionResources, @@ -41,6 +41,28 @@ import { theme } from "../modes/interactive/theme/theme.js"; import { stripFrontmatter } from "../utils/frontmatter.js"; import { sleep } from "../utils/sleep.js"; import { ensureTool, MISSING_RIPGREP_MESSAGE } from "../utils/tools-manager.js"; +import { + AGENT_MESSAGE_SKILL_NAME, + type AgentSessionMessageController, + type AgentSessionMessageListResult, + type AgentSessionMessageReceipt, + assertDirectAgentMessageTarget, + createAgentMessageHostHandlers, + normalizeAgentSessionMessage, + normalizeAgentSessionMessageDeliveryMode, + parseAgentSessionMessagePromptId, +} from "./agent-messages.js"; +import { + AGENT_OBSERVE_SKILL_NAME, + type AgentObserveAgentSnapshot, + type AgentObserveController, + type AgentObserveListResult, + type AgentObserveRecentMessagesResult, + createAgentObserveHostHandlers, + normalizeObserveLimit, + normalizeObserveMaxChars, + ORCHESTRATION_HEARTBEAT_SKILL_NAME, +} from "./agent-observe.js"; import { flushAgentTraceUpload } from "./agent-traces.js"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js"; import { type BashResult, executeBashWithOperations } from "./bash-executor.js"; @@ -277,6 +299,10 @@ export interface AgentSessionConfig { * Default: true. */ includeGoals?: boolean; + /** Daemon-backed agent-to-agent messaging bridge. Omitted for local-only sessions. */ + agentMessageController?: AgentSessionMessageController; + /** Daemon-backed read-only active-session observation bridge. Omitted for local-only sessions. */ + agentObserveController?: AgentObserveController; /** * Optional host-side controller for the bundled rlm-heartbeat Python skill. * When omitted, rlm_heartbeat.* host requests are unavailable. @@ -346,15 +372,51 @@ export interface PromptOptions { /** Source of input for extension input event handlers. Defaults to "interactive". */ source?: InputSource; /** Internal hook used by RPC mode to observe prompt preflight acceptance or rejection. */ - preflightResult?: (success: boolean) => void; + preflightResult?: (success: boolean, queued?: boolean) => void; + /** Queue instead of starting immediately when the session is idle but already has queued work. */ + queueIfBusy?: boolean; +} + +interface InternalPromptOptions extends PromptOptions { + skipPrePromptWork?: boolean; + skipInputHandlers?: boolean; + returnAfterAccepted?: boolean; + agentMessageId?: string; +} + +interface QueuedSteeringMessage { + text: string; + agentMessageId?: string; + message: AgentMessage; } interface QueuedFollowUpMessage { text: string; queueKey?: string; + agentMessageId?: string; message: AgentMessage; } +interface AcceptedAgentMessagePrompt { + text: string; + agentMessageId: string; + message: AgentMessage; + messages: Set; + /** Pending nextTurn messages drained into this prompt; restored to the queue if the prompt is cleared. */ + pendingNextTurnMessages: CustomMessage[]; + accepted: Promise; + resolveAccepted: () => void; + rejectAccepted: (error: Error) => void; + turnStarted: boolean; + cleared: boolean; +} + +interface AgentMessageDeliveryWaiter { + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; +} + /** Result from cycleModel() */ export interface ModelCycleResult { model: Model; @@ -495,7 +557,7 @@ export class AgentSession { private _agentEventQueue: Promise = Promise.resolve(); /** Tracks pending steering messages for UI display. Removed when delivered. */ - private _steeringMessages: string[] = []; + private _steeringMessages: QueuedSteeringMessage[] = []; /** Tracks pending follow-up messages for UI display. Removed when delivered. */ private _followUpMessages: QueuedFollowUpMessage[] = []; /** Messages queued to be included with the next user prompt as context ("asides"). */ @@ -520,6 +582,11 @@ export class AgentSession { private _retryAttempt = 0; private _retryPromise: Promise | undefined = undefined; private _retryResolve: (() => void) | undefined = undefined; + private _acceptedPromptCompletions = new Set>(); + private _acceptedAgentMessagePrompt: AcceptedAgentMessagePrompt | undefined = undefined; + private _agentMessageDeliveryWaiters = new Map(); + private _deliveredAgentMessageIds = new Set(); + private _failedAgentMessageDeliveries = new Map(); // Bash execution state private _bashAbortController: AbortController | undefined = undefined; @@ -541,6 +608,8 @@ export class AgentSession { private _allowedToolNames?: Set; private _includeGoals: boolean; private _rlmHeartbeatController?: AgentRlmHeartbeatController; + private _agentMessageController?: AgentSessionMessageController; + private _agentObserveController?: AgentObserveController; private _mcpManager?: McpManager; private _baseToolsOverride?: Record; private _sessionStartEvent: SessionStartEvent; @@ -617,6 +686,8 @@ export class AgentSession { this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; this._includeGoals = config.includeGoals ?? true; this._rlmHeartbeatController = config.rlmHeartbeatController; + this._agentMessageController = config.agentMessageController; + this._agentObserveController = config.agentObserveController; this._mcpManager = config.mcpManager; this._baseToolsOverride = config.baseToolsOverride; this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" }; @@ -762,7 +833,7 @@ export class AgentSession { private _emitQueueUpdate(): void { this._emit({ type: "queue_update", - steering: [...this._steeringMessages], + steering: this._steeringMessages.map((message) => message.text), followUp: this._followUpMessages.map((message) => message.text), }); } @@ -1300,6 +1371,67 @@ export class AgentSession { } } + handleAgentMessageHostRequest( + type: string, + payload: Record = {}, + ): AgentSessionMessageListResult | Promise { + if (!this._agentMessageController) { + throw new Error("agent messaging is not available in this session"); + } + switch (type) { + case "agent_message.list": + return this._agentMessageController.listAgents(); + case "agent_message.send": { + if (typeof payload.target !== "string") { + throw new Error("agent_message.send target must be a string"); + } + if (typeof payload.message !== "string") { + throw new Error("agent_message.send message must be a string"); + } + const deliveryMode = normalizeAgentSessionMessageDeliveryMode(payload.mode); + return this._agentMessageController.sendAgentMessage({ + target: assertDirectAgentMessageTarget(payload.target), + message: normalizeAgentSessionMessage(payload.message), + ...(deliveryMode ? { deliveryMode } : {}), + }); + } + default: + throw new Error(`unknown agent message request type "${type}"`); + } + } + + handleAgentObserveHostRequest( + type: string, + payload: Record = {}, + ): AgentObserveListResult | AgentObserveAgentSnapshot | AgentObserveRecentMessagesResult { + const controller = this._agentObserveController; + if (!controller) { + throw new Error("agent observation is not available in this session"); + } + switch (type) { + case "agent_observe.list": + return controller.listAgents(); + case "agent_observe.get": { + if (typeof payload.target !== "string") { + throw new Error("agent_observe.get target must be a string"); + } + return controller.getAgent(payload.target); + } + case "agent_observe.recent": { + if (typeof payload.target !== "string") { + throw new Error("agent_observe.recent target must be a string"); + } + return controller.recentMessages({ + target: payload.target, + limit: normalizeObserveLimit(payload.limit as number | undefined), + maxChars: normalizeObserveMaxChars((payload.max_chars ?? payload.maxChars) as number | undefined), + }); + } + default: + throw new Error(`unknown agent observe request type "${type}"`); + } + } + private _createGoalFromHost(objective: string, tokenBudget: number | undefined): GoalState { switch (this._goalState.status) { case "active": @@ -1373,6 +1505,62 @@ export class AgentSession { // Track last assistant message for auto-compaction check private _lastAssistantMessage: AssistantMessage | undefined = undefined; + waitForAgentMessagePromptDelivery(agentMessageId: string): Promise { + if (this._deliveredAgentMessageIds.has(agentMessageId)) { + return Promise.resolve(); + } + const failedDelivery = this._failedAgentMessageDeliveries.get(agentMessageId); + if (failedDelivery) { + return Promise.reject(failedDelivery); + } + let waiter = this._agentMessageDeliveryWaiters.get(agentMessageId); + if (waiter) { + return waiter.promise; + } + let resolveDelivery = () => {}; + let rejectDelivery = (_error: Error) => {}; + const promise = new Promise((resolve, reject) => { + resolveDelivery = resolve; + rejectDelivery = reject; + }); + waiter = { promise, resolve: resolveDelivery, reject: rejectDelivery }; + this._agentMessageDeliveryWaiters.set(agentMessageId, waiter); + void promise + .finally(() => { + if (this._agentMessageDeliveryWaiters.get(agentMessageId) === waiter) { + this._agentMessageDeliveryWaiters.delete(agentMessageId); + } + }) + .catch(() => undefined); + return promise; + } + + private _resolveAgentMessageDelivery(agentMessageId: string | undefined): void { + if (agentMessageId === undefined) { + return; + } + this._failedAgentMessageDeliveries.delete(agentMessageId); + this._deliveredAgentMessageIds.add(agentMessageId); + this._agentMessageDeliveryWaiters.get(agentMessageId)?.resolve(); + } + + private _rejectAgentMessageDelivery(agentMessageId: string | undefined, error: Error): void { + if (agentMessageId === undefined || this._deliveredAgentMessageIds.has(agentMessageId)) { + return; + } + this._failedAgentMessageDeliveries.set(agentMessageId, error); + this._agentMessageDeliveryWaiters.get(agentMessageId)?.reject(error); + } + + private _rejectQueuedAgentMessageDeliveries(error: Error): void { + for (const message of this._steeringMessages) { + this._rejectAgentMessageDelivery(message.agentMessageId, error); + } + for (const message of this._followUpMessages) { + this._rejectAgentMessageDelivery(message.agentMessageId, error); + } + } + /** Internal handler for agent events - shared by subscribe and reconnect */ private _handleAgentEvent = (event: AgentEvent): void => { // Create retry promise synchronously before queueing async processing. @@ -1381,7 +1569,12 @@ export class AgentSession { // _processAgentEvent, slow earlier queued events can delay agent_end processing // and waitForRetry() can miss the in-flight retry. this._createRetryPromiseForAgentEnd(event); - + const acceptedPrompt = this._acceptedAgentMessagePrompt; + if (event.type === "message_start" && acceptedPrompt?.message === event.message && !acceptedPrompt.cleared) { + acceptedPrompt.turnStarted = true; + this._resolveAgentMessageDelivery(acceptedPrompt.agentMessageId); + acceptedPrompt.resolveAccepted(); + } this._agentEventQueue = this._agentEventQueue.then( () => this._processAgentEvent(event), () => this._processAgentEvent(event), @@ -1422,30 +1615,73 @@ export class AgentSession { } private async _processAgentEvent(event: AgentEvent): Promise { + const acceptedPrompt = this._acceptedAgentMessagePrompt; + if (acceptedPrompt && (event.type === "message_start" || event.type === "message_end")) { + if (event.message === acceptedPrompt.message) { + if (event.type === "message_start") { + acceptedPrompt.turnStarted = true; + } + acceptedPrompt.messages.add(event.message); + } else if (acceptedPrompt.turnStarted) { + acceptedPrompt.messages.add(event.message); + } + if (acceptedPrompt.cleared && acceptedPrompt.messages.has(event.message)) { + // Membership filter, not a positional slice: newer prompts or compaction may + // have rewritten state.messages since the clear. + this.agent.state.messages = this.agent.state.messages.filter( + (message) => !acceptedPrompt.messages.has(message), + ); + return; + } + } + const clearedPromptEnded = event.type === "agent_end" ? this._acceptedAgentMessagePrompt : undefined; + const clearedAcceptedPromptEnded = clearedPromptEnded?.cleared === true; + if (clearedAcceptedPromptEnded) { + // Membership filter, not a positional slice: this runs asynchronously after the + // clear, and a newer prompt or compaction may have rewritten state.messages. + this.agent.state.messages = this.agent.state.messages.filter( + (message) => !clearedPromptEnded.messages.has(message), + ); + (this.agent.state as { errorMessage?: string }).errorMessage = undefined; + if (!clearedPromptEnded.turnStarted) { + clearedPromptEnded.rejectAccepted(new Error("Accepted agent message was cleared before delivery.")); + } + this._lastAssistantMessage = undefined; + this._acceptedAgentMessagePrompt = undefined; + this._resolveRetry(); + } + // When a user message starts, check if it's from either queue and remove it BEFORE emitting // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { this._overflowRecoveryAttempted = false; - const messageText = this._getUserMessageText(event.message); - if (messageText) { - // Check steering queue first - const steeringIndex = this._steeringMessages.indexOf(messageText); - if (steeringIndex !== -1) { - this._steeringMessages.splice(steeringIndex, 1); + const steeringIndex = this._steeringMessages.findIndex((message) => message.message === event.message); + if (steeringIndex !== -1) { + const [removed] = this._steeringMessages.splice(steeringIndex, 1); + this._resolveAgentMessageDelivery(removed?.agentMessageId); + this._emitQueueUpdate(); + } else { + const followUpIndex = this._followUpMessages.findIndex((message) => message.message === event.message); + if (followUpIndex !== -1) { + const [removed] = this._followUpMessages.splice(followUpIndex, 1); + this._resolveAgentMessageDelivery(removed?.agentMessageId); this._emitQueueUpdate(); - } else { - // Check follow-up queue - const followUpIndex = this._followUpMessages.findIndex((message) => message.text === messageText); - if (followUpIndex !== -1) { - this._followUpMessages.splice(followUpIndex, 1); - this._emitQueueUpdate(); - } } } } // Emit to extensions first await this._emitExtensionEvent(event); + if ( + (event.type === "message_start" || event.type === "message_end") && + this._acceptedAgentMessagePrompt?.cleared && + this._acceptedAgentMessagePrompt.messages.has(event.message) + ) { + this.agent.state.messages = this.agent.state.messages.filter( + (message) => !this._acceptedAgentMessagePrompt?.messages.has(message), + ); + return; + } // Notify all listeners this._emit(event); @@ -1497,6 +1733,10 @@ export class AgentSession { } } + if (clearedAcceptedPromptEnded) { + return; + } + // Check auto-retry and auto-compaction after agent completes if (event.type === "agent_end" && this._lastAssistantMessage) { const msg = this._lastAssistantMessage; @@ -1526,15 +1766,6 @@ export class AgentSession { } } - /** Extract text content from a message */ - private _getUserMessageText(message: Message): string { - if (message.role !== "user") return ""; - const content = message.content; - if (typeof content === "string") return content; - const textBlocks = content.filter((c) => c.type === "text"); - return textBlocks.map((c) => (c as TextContent).text).join(""); - } - /** Find the last assistant message in agent state (including aborted ones) */ private _findLastAssistantMessage(): AssistantMessage | undefined { const messages = this.agent.state.messages; @@ -1746,6 +1977,7 @@ export class AgentSession { } this._retainedRlmChildSessions.clear(); this._pendingNextTurnMessages = []; + this._rejectQueuedAgentMessageDeliveries(new Error("Queued agent message was cleared before delivery.")); this._steeringMessages = []; this._followUpMessages = []; this.agent.clearAllQueues(); @@ -1973,18 +2205,52 @@ export class AgentSession { * @throws Error if no model selected or no API key available (when not streaming) */ async prompt(text: string, options?: PromptOptions): Promise { + return this._prompt(text, options); + } + + async acceptAgentMessagePrompt(text: string, options?: PromptOptions): Promise { + return this._prompt(text, { + ...options, + expandPromptTemplates: false, + skipInputHandlers: true, + skipPrePromptWork: true, + returnAfterAccepted: true, + agentMessageId: parseAgentSessionMessagePromptId(text), + }); + } + + async queueAgentMessagePrompt(text: string, streamingBehavior: "steer" | "followUp"): Promise { + const agentMessageId = parseAgentSessionMessagePromptId(text); + if (streamingBehavior === "steer") { + await this._queueSteer(text, undefined, { agentMessageId }); + return true; + } + return this._queueFollowUp(text, undefined, { agentMessageId }); + } + + private async _prompt(text: string, options?: InternalPromptOptions): Promise { const expandPromptTemplates = options?.expandPromptTemplates ?? true; const preflightResult = options?.preflightResult; + let preflightSettled = false; + const reportPreflight = (success: boolean, queued = false) => { + if (!preflightSettled) { + preflightSettled = true; + preflightResult?.(success, queued); + } + }; let messages: AgentMessage[] | undefined; + let acceptedAgentMessagePrompt: AcceptedAgentMessagePrompt | undefined; + let drainedNextTurnMessages: CustomMessage[] = []; + let expandedText = text; + let currentImages = options?.images; try { let currentText = text; - let currentImages = options?.images; if (expandPromptTemplates) { const handledGoalCommand = await this._handleGoalSlashCommand(currentText, currentImages); if (handledGoalCommand) { - preflightResult?.(true); + reportPreflight(true); return; } } @@ -1995,20 +2261,22 @@ export class AgentSession { const handled = await this._tryExecuteExtensionCommand(currentText); if (handled) { // Extension command executed, no prompt to send - preflightResult?.(true); + reportPreflight(true); return; } } - // Emit input event for extension interception (before skill/template expansion) - if (this._extensionRunner.hasHandlers("input")) { + // Emit input event for extension interception (before skill/template expansion). + // Agent-to-agent messages use acceptAgentMessagePrompt(), which bypasses + // input handlers so extensions cannot rewrite or swallow direct delivery. + if (!options?.skipInputHandlers && this._extensionRunner.hasHandlers("input")) { const inputResult = await this._extensionRunner.emitInput( currentText, currentImages, options?.source ?? "interactive", ); if (inputResult.action === "handled") { - preflightResult?.(true); + reportPreflight(true); return; } if (inputResult.action === "transform") { @@ -2018,31 +2286,53 @@ export class AgentSession { } // Expand skill commands (/skill:name args) and prompt templates (/template args) - let expandedText = currentText; + expandedText = currentText; if (expandPromptTemplates) { expandedText = this._expandSkillCommand(expandedText); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); } - // If streaming, queue via steer() or followUp() based on option - if (this.isStreaming) { + // If streaming, or a caller explicitly asked to respect existing queued work, + // enqueue according to the requested behavior. + const shouldQueueForStreaming = this.isStreaming; + const shouldQueueForPendingWork = + options?.queueIfBusy === true && + (this.pendingMessageCount > 0 || + this.isCompacting || + this.isRetrying || + this.isBashRunning || + this.hasAcceptedPromptInFlight); + if (shouldQueueForStreaming || shouldQueueForPendingWork) { if (!options?.streamingBehavior) { + const stateDescription = shouldQueueForStreaming + ? "Agent is already processing" + : "Agent has queued work"; throw new Error( - "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.", + `${stateDescription}. Specify streamingBehavior ('steer' or 'followUp') to queue the message.`, ); } - if (options.streamingBehavior === "followUp") { - await this._queueFollowUp(expandedText, currentImages, { queueKey: options.followUpQueueKey }); - } else { - await this._queueSteer(expandedText, currentImages); + const queued = await this._queuePromptWithPendingNextTurnMessages( + expandedText, + currentImages, + options.streamingBehavior, + { + queueKey: options.followUpQueueKey, + agentMessageId: options.agentMessageId, + }, + ); + if (!queued) { + reportPreflight(false); + return; } - preflightResult?.(true); + reportPreflight(true, true); return; } - await this._waitForRefineIdle(); + if (!options?.returnAfterAccepted) { + await this._waitForRefineIdle(); + } - // Flush any pending bash messages before the new prompt + // Flush any pending bash messages before the new prompt, including accepted agent messages. this._flushPendingBashMessages(); // Validate model @@ -2061,62 +2351,103 @@ export class AgentSession { } throw new Error(formatNoApiKeyFoundMessage(this.model.provider)); } + if (options?.skipPrePromptWork) { + this.agent.state.systemPrompt = this._baseSystemPrompt; + messages = []; + drainedNextTurnMessages = this._pendingNextTurnMessages; + for (const msg of drainedNextTurnMessages) { + messages.push(msg); + } + this._pendingNextTurnMessages = []; + const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; + if (currentImages) { + userContent.push(...currentImages); + } + const userMessage: AgentMessage = { + role: "user", + content: userContent, + timestamp: Date.now(), + }; + messages.push(userMessage); + if (options.agentMessageId !== undefined && options.returnAfterAccepted) { + let resolveAccepted = () => {}; + let rejectAccepted = (_error: Error) => {}; + const accepted = new Promise((resolve, reject) => { + resolveAccepted = resolve; + rejectAccepted = reject; + }); + acceptedAgentMessagePrompt = { + text: expandedText, + agentMessageId: options.agentMessageId, + message: userMessage, + messages: new Set([...drainedNextTurnMessages, userMessage]), + pendingNextTurnMessages: drainedNextTurnMessages, + accepted, + resolveAccepted, + rejectAccepted, + turnStarted: false, + cleared: false, + }; + } + } else { + // Check if we need to compact before sending (catches aborted responses) + const lastAssistant = this._findLastAssistantMessage(); + if (lastAssistant) { + await this._checkCompaction(lastAssistant, false); + } - // Check if we need to compact before sending (catches aborted responses) - const lastAssistant = this._findLastAssistantMessage(); - if (lastAssistant) { - await this._checkCompaction(lastAssistant, false); - } - - // Build messages array (custom message if any, then user message) - messages = []; + // Build messages array (custom message if any, then user message) + messages = []; - // Inject any pending "nextTurn" messages as context before the user message. - for (const msg of this._pendingNextTurnMessages) { - messages.push(msg); - } - this._pendingNextTurnMessages = []; + // Inject any pending "nextTurn" messages as context before the user message. + drainedNextTurnMessages = this._pendingNextTurnMessages; + for (const msg of drainedNextTurnMessages) { + messages.push(msg); + } + this._pendingNextTurnMessages = []; - // Add user message - const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; - if (currentImages) { - userContent.push(...currentImages); - } - messages.push({ - role: "user", - content: userContent, - timestamp: Date.now(), - }); + // Add user message + const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; + if (currentImages) { + userContent.push(...currentImages); + } + const userMessage: AgentMessage = { + role: "user", + content: userContent, + timestamp: Date.now(), + }; + messages.push(userMessage); - // Emit before_agent_start extension event - const result = await this._extensionRunner.emitBeforeAgentStart( - expandedText, - currentImages, - this._baseSystemPrompt, - this._baseSystemPromptOptions, - ); - // Add all custom messages from extensions - if (result?.messages) { - for (const msg of result.messages) { - messages.push({ - role: "custom", - customType: msg.customType, - content: msg.content, - display: msg.display, - details: msg.details, - timestamp: Date.now(), - }); + // Emit before_agent_start extension event + const result = await this._extensionRunner.emitBeforeAgentStart( + expandedText, + currentImages, + this._baseSystemPrompt, + this._baseSystemPromptOptions, + ); + // Add all custom messages from extensions + if (result?.messages) { + for (const msg of result.messages) { + messages.push({ + role: "custom", + customType: msg.customType, + content: msg.content, + display: msg.display, + details: msg.details, + timestamp: Date.now(), + }); + } + } + // Apply extension-modified system prompt, or reset to base + if (result?.systemPrompt) { + this.agent.state.systemPrompt = result.systemPrompt; + } else { + // Ensure we're using the base prompt (in case previous turn had modifications) + this.agent.state.systemPrompt = this._baseSystemPrompt; } - } - // Apply extension-modified system prompt, or reset to base - if (result?.systemPrompt) { - this.agent.state.systemPrompt = result.systemPrompt; - } else { - // Ensure we're using the base prompt (in case previous turn had modifications) - this.agent.state.systemPrompt = this._baseSystemPrompt; } } catch (error) { - preflightResult?.(false); + reportPreflight(false); throw error; } @@ -2124,12 +2455,123 @@ export class AgentSession { return; } - preflightResult?.(true); + if (acceptedAgentMessagePrompt) { + this._acceptedAgentMessagePrompt = acceptedAgentMessagePrompt; + } // Re-check adjacent to the handoff: extension before_agent_start handlers // above may have suspended this turn long enough for a refine to start. - await this._waitForRefineIdle(); - await this.agent.prompt(messages); - await this.waitForRetry(); + if (this._refineInFlight) { + await this._waitForRefineIdle(); + } + if (acceptedAgentMessagePrompt?.cleared) { + reportPreflight(false); + throw new Error("Accepted agent message was cleared before delivery."); + } + const shouldQueueAtHandoff = + options?.queueIfBusy === true && + (this.isStreaming || + this.pendingMessageCount > 0 || + this.isCompacting || + this.isRetrying || + this.isBashRunning || + this._acceptedPromptCompletions.size > 0 || + (this._acceptedAgentMessagePrompt !== undefined && + this._acceptedAgentMessagePrompt !== acceptedAgentMessagePrompt)); + if (shouldQueueAtHandoff) { + if (!options?.streamingBehavior) { + if (acceptedAgentMessagePrompt && this._acceptedAgentMessagePrompt === acceptedAgentMessagePrompt) { + this._acceptedAgentMessagePrompt = undefined; + } + this._pendingNextTurnMessages.unshift(...drainedNextTurnMessages.map((message) => ({ ...message }))); + reportPreflight(false); + throw new Error( + "Agent became busy before prompt delivery. Specify streamingBehavior ('steer' or 'followUp') to queue the message.", + ); + } + if (acceptedAgentMessagePrompt && this._acceptedAgentMessagePrompt === acceptedAgentMessagePrompt) { + this._acceptedAgentMessagePrompt = undefined; + } + this._pendingNextTurnMessages.unshift(...drainedNextTurnMessages.map((message) => ({ ...message }))); + const queued = await this._queuePromptWithPendingNextTurnMessages( + expandedText, + currentImages, + options.streamingBehavior, + { + queueKey: options.followUpQueueKey, + agentMessageId: options.agentMessageId, + }, + ); + if (!queued) { + reportPreflight(false); + return; + } + reportPreflight(true, true); + return; + } + const promptPromise = this.agent.prompt(messages); + const promptAccepted = Symbol("promptAccepted"); + const acceptance = acceptedAgentMessagePrompt + ? acceptedAgentMessagePrompt.accepted.then( + () => promptAccepted, + (error: unknown) => error, + ) + : new Promise((resolve) => { + setTimeout(() => resolve(promptAccepted), 0); + }); + const firstOutcome = await Promise.race([ + promptPromise.then( + () => undefined, + (error: unknown) => error, + ), + acceptance, + ]); + if (firstOutcome !== undefined && firstOutcome !== promptAccepted) { + // A cleared prompt stays set until the aborted run's agent_end cleanup nulls it; + // nulling here would let the run's late events re-persist cleared messages. + if ( + this._acceptedAgentMessagePrompt === acceptedAgentMessagePrompt && + !this._acceptedAgentMessagePrompt?.cleared + ) { + this._acceptedAgentMessagePrompt = undefined; + } + if (acceptedAgentMessagePrompt && !acceptedAgentMessagePrompt.cleared) { + // The prompt was never accepted, so next-turn context drained for it + // was not consumed by the model and must remain available to retry. + this._pendingNextTurnMessages.unshift( + ...acceptedAgentMessagePrompt.pendingNextTurnMessages.map((message) => ({ ...message })), + ); + } + reportPreflight(false); + throw firstOutcome; + } + reportPreflight(true); + const promptCompletion = promptPromise.then(async () => { + await this.waitForRetry(); + }); + void promptCompletion + .finally(() => { + if ( + this._acceptedAgentMessagePrompt === acceptedAgentMessagePrompt && + !this._acceptedAgentMessagePrompt?.cleared + ) { + this._acceptedAgentMessagePrompt = undefined; + } + }) + .catch(() => undefined); + if (options?.returnAfterAccepted) { + this._acceptedPromptCompletions.add(promptCompletion); + void promptCompletion.then( + () => { + this._acceptedPromptCompletions.delete(promptCompletion); + }, + () => { + this._acceptedPromptCompletions.delete(promptCompletion); + }, + ); + void promptCompletion.catch(() => undefined); + return; + } + await promptCompletion; } /** @@ -2233,21 +2675,72 @@ export class AgentSession { return this._queueFollowUp(expandedText, images, { queueKey: options.queueKey }); } - /** - * Internal: Queue a steering message (already expanded, no extension command check). - */ - private async _queueSteer(text: string, images?: ImageContent[]): Promise { - this._steeringMessages.push(text); - this._emitQueueUpdate(); - const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; + private _buildPromptContent( + text: string, + images?: ImageContent[], + prefixMessages: readonly CustomMessage[] = [], + ): (TextContent | ImageContent)[] { + const content: (TextContent | ImageContent)[] = []; + for (const message of prefixMessages) { + if (typeof message.content === "string") { + content.push({ type: "text", text: message.content }); + } else { + content.push(...message.content); + } + } + content.push({ type: "text", text }); if (images) { content.push(...images); } - this.agent.steer({ + return content; + } + + private async _queuePromptWithPendingNextTurnMessages( + text: string, + images: ImageContent[] | undefined, + streamingBehavior: "steer" | "followUp", + options: { queueKey?: string; agentMessageId?: string } = {}, + ): Promise { + const pendingNextTurnMessages = this._pendingNextTurnMessages; + this._pendingNextTurnMessages = []; + const content = this._buildPromptContent(text, images, pendingNextTurnMessages); + try { + if (streamingBehavior === "followUp") { + const queued = await this._queueFollowUp(text, undefined, { ...options, content }); + if (!queued) { + this._pendingNextTurnMessages.unshift(...pendingNextTurnMessages); + } + return queued; + } + await this._queueSteer(text, undefined, { agentMessageId: options.agentMessageId, content }); + return true; + } catch (error) { + this._pendingNextTurnMessages.unshift(...pendingNextTurnMessages); + throw error; + } + } + + /** + * Internal: Queue a steering message (already expanded, no extension command check). + */ + private async _queueSteer( + text: string, + images?: ImageContent[], + options: { agentMessageId?: string; content?: (TextContent | ImageContent)[] } = {}, + ): Promise { + const content = options.content ?? this._buildPromptContent(text, images); + const message: AgentMessage = { role: "user", content, timestamp: Date.now(), + }; + this._steeringMessages.push({ + text, + agentMessageId: options.agentMessageId, + message, }); + this.agent.steer(message); + this._emitQueueUpdate(); } /** @@ -2256,23 +2749,25 @@ export class AgentSession { private async _queueFollowUp( text: string, images?: ImageContent[], - options: { queueKey?: string } = {}, + options: { queueKey?: string; agentMessageId?: string; content?: (TextContent | ImageContent)[] } = {}, ): Promise { if (options.queueKey && this._followUpMessages.some((message) => message.queueKey === options.queueKey)) { return false; } - const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; - if (images) { - content.push(...images); - } + const content = options.content ?? this._buildPromptContent(text, images); const message: AgentMessage = { role: "user", content, timestamp: Date.now(), }; - this._followUpMessages.push({ text, queueKey: options.queueKey, message }); - this._emitQueueUpdate(); + this._followUpMessages.push({ + text, + queueKey: options.queueKey, + agentMessageId: options.agentMessageId, + message, + }); this.agent.followUp(message); + this._emitQueueUpdate(); return true; } @@ -2385,8 +2880,9 @@ export class AgentSession { * @returns Object with steering and followUp arrays */ clearQueue(): { steering: string[]; followUp: string[] } { - const steering = [...this._steeringMessages]; + const steering = this._steeringMessages.map((message) => message.text); const followUp = this._followUpMessages.map((message) => message.text); + this._rejectQueuedAgentMessageDeliveries(new Error("Queued agent message was cleared before delivery.")); this._steeringMessages = []; this._followUpMessages = []; this.agent.clearAllQueues(); @@ -2394,6 +2890,65 @@ export class AgentSession { return { steering, followUp }; } + clearQueuedUserMessagesMatching(predicate: (text: string) => boolean): { steering: string[]; followUp: string[] } { + const steering = this._steeringMessages.filter( + (message) => message.agentMessageId !== undefined && predicate(message.text), + ); + const followUp = this._followUpMessages.filter( + (message) => message.agentMessageId !== undefined && predicate(message.text), + ); + const accepted = this._acceptedAgentMessagePrompt; + const acceptedMatches = + accepted !== undefined && !accepted.turnStarted && !accepted.cleared && predicate(accepted.text); + if (steering.length === 0 && followUp.length === 0 && !acceptedMatches) { + return { steering: [], followUp: [] }; + } + const steeringToRemove = new Set(steering.map((message) => message.message)); + const followUpToRemove = new Set(followUp.map((message) => message.message)); + const removedQueuedMessages = new Set( + this.agent.removeQueuedMessages( + (message) => message.role === "user" && (steeringToRemove.has(message) || followUpToRemove.has(message)), + ), + ); + const removedSteeringMessages = steering.filter((message) => removedQueuedMessages.has(message.message)); + const removedFollowUpMessages = followUp.filter((message) => removedQueuedMessages.has(message.message)); + if (removedSteeringMessages.length === 0 && removedFollowUpMessages.length === 0 && !acceptedMatches) { + return { steering: [], followUp: [] }; + } + const removedSteeringSet = new Set(removedSteeringMessages.map((message) => message.message)); + const removedFollowUpSet = new Set(removedFollowUpMessages.map((message) => message.message)); + this._steeringMessages = this._steeringMessages.filter((message) => !removedSteeringSet.has(message.message)); + this._followUpMessages = this._followUpMessages.filter((message) => !removedFollowUpSet.has(message.message)); + const removedSteering = removedSteeringMessages.map((message) => message.text); + const removedFollowUp = removedFollowUpMessages.map((message) => message.text); + for (const message of removedSteeringMessages) { + this._rejectAgentMessageDelivery( + message.agentMessageId, + new Error("Queued agent message was cleared before delivery."), + ); + } + for (const message of removedFollowUpMessages) { + this._rejectAgentMessageDelivery( + message.agentMessageId, + new Error("Queued agent message was cleared before delivery."), + ); + } + if (acceptedMatches) { + accepted.cleared = true; + this.agent.state.messages = this.agent.state.messages.filter((message) => !accepted.messages.has(message)); + // Restore drained nextTurn messages the model never saw. Clones, so the cleared + // run's late-event cleanup cannot strip the restored copies from a newer run. + this._pendingNextTurnMessages.unshift(...accepted.pendingNextTurnMessages.map((message) => ({ ...message }))); + const error = new Error("Accepted agent message was cleared before delivery."); + this._rejectAgentMessageDelivery(accepted.agentMessageId, error); + accepted.rejectAccepted(error); + this.agent.abort(); + removedFollowUp.push(accepted.text); + } + this._emitQueueUpdate(); + return { steering: removedSteering, followUp: removedFollowUp }; + } + /** Number of pending messages (includes both steering and follow-up) */ get pendingMessageCount(): number { return this._steeringMessages.length + this._followUpMessages.length; @@ -2401,7 +2956,7 @@ export class AgentSession { /** Get pending steering messages (read-only) */ getSteeringMessages(): readonly string[] { - return this._steeringMessages; + return this._steeringMessages.map((message) => message.text); } /** Get pending follow-up messages (read-only) */ @@ -2420,6 +2975,12 @@ export class AgentSession { } this._followUpMessages = this._followUpMessages.filter((message) => message.queueKey !== queueKey); const removedMessages = new Set(removed.map((message) => message.message)); + for (const message of removed) { + this._rejectAgentMessageDelivery( + message.agentMessageId, + new Error("Queued agent message was cleared before delivery."), + ); + } this.agent.removeQueuedMessages((message) => removedMessages.has(message)); this._emitQueueUpdate(); return true; @@ -4044,11 +4605,20 @@ export class AgentSession { * skill is withheld when goals are disabled for this session. */ private _modelVisibleSkills(): Skill[] { - const skills = this._resourceLoader.getSkills().skills; - if (this._includeGoals) { - return skills; + let skills = this._resourceLoader.getSkills().skills; + if (!this._includeGoals) { + skills = skills.filter((skill) => skill.name !== GOAL_SKILL_NAME); } - return skills.filter((skill) => skill.name !== GOAL_SKILL_NAME); + if (!this._agentMessageController) { + skills = skills.filter((skill) => skill.name !== AGENT_MESSAGE_SKILL_NAME); + } + if (!this._agentObserveController) { + skills = skills.filter((skill) => skill.name !== AGENT_OBSERVE_SKILL_NAME); + } + if (!this._agentObserveController || !this._rlmHeartbeatController) { + skills = skills.filter((skill) => skill.name !== ORCHESTRATION_HEARTBEAT_SKILL_NAME); + } + return skills; } /** Typed handlers for host requests arriving from the IPython kernel comm bridge. */ @@ -4078,6 +4648,42 @@ export class AgentSession { handlers[type] = async (payload) => this.handleRlmHeartbeatHostRequest(type, payload); } } + const visibleKernelSkillNames = new Set( + this._modelVisibleSkills() + .filter((skill) => !skill.disableModelInvocation) + .map((skill) => skill.name), + ); + if (this._agentMessageController && visibleKernelSkillNames.has(AGENT_MESSAGE_SKILL_NAME)) { + Object.assign( + handlers, + createAgentMessageHostHandlers({ + listAgents: () => + this.handleAgentMessageHostRequest("agent_message.list") as AgentSessionMessageListResult, + sendAgentMessage: async (input) => + (await this.handleAgentMessageHostRequest("agent_message.send", { + target: input.target, + message: input.message, + mode: input.deliveryMode, + })) as AgentSessionMessageReceipt, + }), + ); + } + if (this._agentObserveController) { + Object.assign( + handlers, + createAgentObserveHostHandlers({ + listAgents: () => this.handleAgentObserveHostRequest("agent_observe.list") as AgentObserveListResult, + getAgent: (target) => + this.handleAgentObserveHostRequest("agent_observe.get", { target }) as AgentObserveAgentSnapshot, + recentMessages: (input) => + this.handleAgentObserveHostRequest("agent_observe.recent", { + target: input.target, + limit: input.limit, + max_chars: input.maxChars, + }) as AgentObserveRecentMessagesResult, + }), + ); + } if (this._mcpManager) { Object.assign(handlers, this._mcpManager.hostHandlers()); } @@ -4801,6 +5407,11 @@ export class AgentSession { return this._retryPromise !== undefined; } + /** Whether an accepted prompt is still running or waiting for retry completion. */ + get hasAcceptedPromptInFlight(): boolean { + return this._acceptedPromptCompletions.size > 0 || this._acceptedAgentMessagePrompt !== undefined; + } + /** Whether auto-retry is enabled */ get autoRetryEnabled(): boolean { return this.settingsManager.getRetryEnabled(); @@ -4882,6 +5493,53 @@ export class AgentSession { // Emitted after the slot is released so clients never observe a bash_end // while the session still rejects new commands as already running. this._emit({ type: "bash_end", ...end }); + void this._drainQueuedMessagesAfterBash().catch(() => undefined); + } + + private async _drainQueuedMessagesAfterBash(): Promise { + await this.agent.waitForIdle(); + if ( + this.isStreaming || + this.isCompacting || + this.isRetrying || + this.hasAcceptedPromptInFlight || + this.pendingMessageCount === 0 + ) { + return; + } + + const steeringMessages = [...this._steeringMessages]; + const followUpMessages = [...this._followUpMessages]; + const drainedSteeringMessages = steeringMessages.length > 0 ? steeringMessages : []; + const drainedFollowUpMessages = steeringMessages.length > 0 ? [] : followUpMessages; + const queuedMessages = [...drainedSteeringMessages, ...drainedFollowUpMessages].map((message) => message.message); + if (queuedMessages.length === 0) { + return; + } + + const queuedMessageSet = new Set(queuedMessages); + this.agent.removeQueuedMessages((message) => queuedMessageSet.has(message)); + this._flushPendingBashMessages(); + const nextTurnMessages = this._pendingNextTurnMessages; + this._pendingNextTurnMessages = []; + try { + await this.agent.prompt([...nextTurnMessages, ...queuedMessages]); + await this.waitForRetry(); + } catch { + this._pendingNextTurnMessages.unshift(...nextTurnMessages.map((message) => ({ ...message }))); + const queuedSteering = new Set(this._steeringMessages.map((message) => message.message)); + const queuedFollowUps = new Set(this._followUpMessages.map((message) => message.message)); + for (const queued of drainedSteeringMessages) { + if (queuedSteering.has(queued.message)) { + this.agent.steer(queued.message); + } + } + for (const queued of drainedFollowUpMessages) { + if (queuedFollowUps.has(queued.message)) { + this.agent.followUp(queued.message); + } + } + } } private async runUserBashLocked(command: string, excludeFromContext: boolean): Promise { diff --git a/packages/coding-agent/src/core/cron-jobs.ts b/packages/coding-agent/src/core/cron-jobs.ts index 457b97e0e9..18f605b580 100644 --- a/packages/coding-agent/src/core/cron-jobs.ts +++ b/packages/coding-agent/src/core/cron-jobs.ts @@ -61,7 +61,10 @@ export interface AgentCronSchedulerHooks { export interface HeartbeatCronSessionActivity { isStreaming: boolean; + isCompacting?: boolean; + isRetrying?: boolean; isBashRunning: boolean; + hasAcceptedPromptInFlight?: boolean; pendingMessageCount: number; } @@ -833,7 +836,13 @@ export function isHeartbeatCronJob(job: AgentCronJob): boolean { export function shouldDeferHeartbeatCronJob(job: AgentCronJob, activity: HeartbeatCronSessionActivity): boolean { return ( - isHeartbeatCronJob(job) && (activity.isStreaming || activity.isBashRunning || activity.pendingMessageCount > 0) + isHeartbeatCronJob(job) && + (activity.isStreaming || + activity.isCompacting === true || + activity.isRetrying === true || + activity.isBashRunning || + activity.hasAcceptedPromptInFlight === true || + activity.pendingMessageCount > 0) ); } diff --git a/packages/coding-agent/src/core/kernel/bootstrap.ts b/packages/coding-agent/src/core/kernel/bootstrap.ts index cc40abbbc3..33933e93e8 100644 --- a/packages/coding-agent/src/core/kernel/bootstrap.ts +++ b/packages/coding-agent/src/core/kernel/bootstrap.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { constants, existsSync, readFileSync } from "node:fs"; +import { constants, existsSync, readdirSync, readFileSync } from "node:fs"; import { access, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -129,16 +129,29 @@ function fileContentHash(filePath: string): string { function normalizePythonSkills(pythonSkills: readonly KernelPythonSkill[] | undefined): BootstrapPythonSkill[] { const byKey = new Map(); - for (const skill of pythonSkills ?? []) { + const addSkill = (skill: Pick): void => { const packagePath = path.resolve(skill.packagePath); const pyprojectPath = path.resolve(skill.pyprojectPath); const key = `${skill.importName}\0${packagePath}`; - byKey.set(key, { + if (byKey.has(key)) { + return; + } + const bootstrapSkill: BootstrapPythonSkill = { importName: skill.importName, packagePath, pyprojectPath, pyprojectHash: fileContentHash(pyprojectPath), - }); + }; + byKey.set(key, bootstrapSkill); + for (const dependencyName of readPythonSkillDependencyNames(bootstrapSkill)) { + const siblingDependency = resolveSiblingPythonSkillDependency(bootstrapSkill, dependencyName); + if (siblingDependency) { + addSkill(siblingDependency); + } + } + }; + for (const skill of pythonSkills ?? []) { + addSkill(skill); } return [...byKey.values()].sort((a, b) => { const packageCompare = a.packagePath.localeCompare(b.packagePath); @@ -147,6 +160,172 @@ function normalizePythonSkills(pythonSkills: readonly KernelPythonSkill[] | unde }); } +function readTomlProjectSection(pyprojectPath: string): string | undefined { + try { + const text = readFileSync(pyprojectPath, "utf-8"); + const match = text.match(/^\s*\[project\]\s*$/m); + if (!match || match.index === undefined) { + return undefined; + } + const sectionStart = match.index + match[0].length; + const rest = text.slice(sectionStart); + const nextSection = rest.search(/^\s*\[/m); + return nextSection >= 0 ? rest.slice(0, nextSection) : rest; + } catch { + return undefined; + } +} + +function readPythonSkillProjectName(skill: BootstrapPythonSkill): string { + const projectSection = readTomlProjectSection(skill.pyprojectPath); + const name = projectSection?.match(/^\s*name\s*=\s*["']([^"']+)["']/m)?.[1]; + return name?.trim() || skill.importName.replaceAll("_", "-"); +} + +function parseDependencyPackageName(dependency: string): string | undefined { + const withoutMarker = dependency.split(";")[0]?.trim() ?? ""; + if (!withoutMarker) { + return undefined; + } + const match = withoutMarker.match(/^([A-Za-z0-9_.-]+)/); + return match?.[1]?.replaceAll("_", "-").toLowerCase(); +} + +function findTomlArrayEnd(text: string, startIndex: number): number { + let inQuote: '"' | "'" | undefined; + let escaped = false; + for (let index = startIndex; index < text.length; index++) { + const char = text[index]; + if (inQuote) { + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === inQuote) { + inQuote = undefined; + } + continue; + } + if (char === '"' || char === "'") { + inQuote = char; + continue; + } + if (char === "]") { + return index; + } + } + return -1; +} + +function readPythonSkillDependencyNames(skill: BootstrapPythonSkill): Set { + const projectSection = readTomlProjectSection(skill.pyprojectPath); + if (!projectSection) { + return new Set(); + } + const dependenciesStart = projectSection.search(/^\s*dependencies\s*=\s*\[/m); + if (dependenciesStart < 0) { + return new Set(); + } + const arrayStart = projectSection.indexOf("[", dependenciesStart); + if (arrayStart < 0) { + return new Set(); + } + const arrayEnd = findTomlArrayEnd(projectSection, arrayStart + 1); + if (arrayEnd < 0) { + return new Set(); + } + const dependenciesArray = projectSection.slice(arrayStart, arrayEnd + 1); + const dependencies = new Set(); + const dependencyPattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/g; + for (const match of dependenciesArray.matchAll(dependencyPattern)) { + const dependency = (match[1] ?? match[2] ?? "").replaceAll('\\"', '"').replaceAll("\\'", "'"); + const name = parseDependencyPackageName(dependency); + if (name) { + dependencies.add(name); + } + } + return dependencies; +} + +function resolveSiblingPythonSkillDependency( + skill: BootstrapPythonSkill, + dependencyName: string, +): BootstrapPythonSkill | undefined { + const siblingsDir = path.dirname(skill.packagePath); + for (const entry of readdirSync(siblingsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const packagePath = path.join(siblingsDir, entry.name); + const pyprojectPath = path.join(packagePath, "pyproject.toml"); + if (!existsSync(pyprojectPath)) { + continue; + } + const dependency: BootstrapPythonSkill = { + importName: entry.name.replaceAll("-", "_"), + packagePath, + pyprojectPath, + pyprojectHash: fileContentHash(pyprojectPath), + }; + if (readPythonSkillProjectName(dependency).replaceAll("_", "-").toLowerCase() === dependencyName) { + return dependency; + } + } + return undefined; +} + +function sortPythonSkillsForInstall(pythonSkills: readonly BootstrapPythonSkill[]): BootstrapPythonSkill[] { + const byProjectName = new Map(); + const originalIndex = new Map(); + for (const [index, skill] of pythonSkills.entries()) { + originalIndex.set(skill, index); + byProjectName.set(readPythonSkillProjectName(skill).replaceAll("_", "-").toLowerCase(), skill); + } + + const dependenciesBySkill = new Map(); + for (const skill of pythonSkills) { + dependenciesBySkill.set( + skill, + [...readPythonSkillDependencyNames(skill)] + .map( + (dependencyName) => + byProjectName.get(dependencyName) ?? resolveSiblingPythonSkillDependency(skill, dependencyName), + ) + .filter((dependency): dependency is BootstrapPythonSkill => Boolean(dependency)), + ); + } + + const pending = new Set(pythonSkills); + const sorted: BootstrapPythonSkill[] = []; + while (pending.size > 0) { + let progressed = false; + for (const skill of [...pending].sort((a, b) => (originalIndex.get(a) ?? 0) - (originalIndex.get(b) ?? 0))) { + const dependencies = dependenciesBySkill.get(skill) ?? []; + if (dependencies.some((dependency) => pending.has(dependency))) { + continue; + } + sorted.push(skill); + pending.delete(skill); + progressed = true; + } + if (!progressed) { + // Cyclic local skill dependencies cannot be topologically ordered; keep a + // deterministic order and let uv surface the packaging error if needed. + sorted.push(...[...pending].sort((a, b) => a.packagePath.localeCompare(b.packagePath))); + break; + } + } + return sorted; +} + +function formatPythonSkillInstallArgs(skill: BootstrapPythonSkill): string[] { + return ["--editable", skill.packagePath]; +} + function ensureKernelPythonKey(pythonSkills: readonly BootstrapPythonSkill[]): string { return [ process.env.PRIME_AGENT_KERNEL_PYTHON ?? "", @@ -650,17 +829,61 @@ async function syncPythonSkills( const currentPythonSkills = new Map( (version?.pythonSkills ?? []).map((skill) => [`${skill.importName}\0${skill.packagePath}`, skill]), ); + const pythonSkillsByProjectName = new Map( + pythonSkills.map((skill) => [readPythonSkillProjectName(skill).replaceAll("_", "-").toLowerCase(), skill]), + ); + const dependenciesBySkill = new Map( + pythonSkills.map((skill) => [ + skill, + [...readPythonSkillDependencyNames(skill)] + .map( + (dependencyName) => + pythonSkillsByProjectName.get(dependencyName) ?? + resolveSiblingPythonSkillDependency(skill, dependencyName), + ) + .filter((dependency): dependency is BootstrapPythonSkill => Boolean(dependency)), + ]), + ); - for (const skill of pythonSkills) { + for (const skill of sortPythonSkillsForInstall(pythonSkills)) { const existingSkill = currentPythonSkills.get(`${skill.importName}\0${skill.packagePath}`); if (existingSkill?.pyprojectPath === skill.pyprojectPath && existingSkill.pyprojectHash === skill.pyprojectHash) { installedPythonSkills.push(skill); continue; } + const localDependencies = dependenciesBySkill.get(skill) ?? []; + const localDependencyArgs = localDependencies + .filter((dependency) => { + const installedDependency = currentPythonSkills.get(`${dependency.importName}\0${dependency.packagePath}`); + const installedThisSync = installedPythonSkills.some( + (installed) => + installed.importName === dependency.importName && + installed.packagePath === dependency.packagePath && + installed.pyprojectPath === dependency.pyprojectPath && + installed.pyprojectHash === dependency.pyprojectHash, + ); + return !( + installedThisSync || + (installedDependency?.pyprojectPath === dependency.pyprojectPath && + installedDependency.pyprojectHash === dependency.pyprojectHash) + ); + }) + .flatMap(formatPythonSkillInstallArgs); + try { - await run(uv, ["pip", "install", "--python", python, "--editable", skill.packagePath]); - installedPythonSkills.push(skill); + await run(uv, [ + "pip", + "install", + "--python", + python, + ...formatPythonSkillInstallArgs(skill), + ...localDependencyArgs, + ]); + installedPythonSkills.push( + skill, + ...localDependencies.filter((dependency) => !installedPythonSkills.includes(dependency)), + ); } catch (error) { reportProgress( options, diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 5cbc4ec961..9b41469d1b 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -368,6 +368,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} allowedToolNames, includeGoals, rlmHeartbeatController: options.rlmHeartbeatController, + agentMessageController: options.agentMessageController, + agentObserveController: options.agentObserveController, extensionRunnerRef, rlmDepth: options.rlmDepth, rlmMaxDepth: options.rlmMaxDepth, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 3510077cf6..83689093cf 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -687,6 +687,8 @@ export function resolveRuntimeSessionOptions( allowedToolNames: runtimeSessionOptions?.allowedToolNames, includeGoals: runtimeSessionOptions?.includeGoals, rlmHeartbeatController: runtimeSessionOptions?.rlmHeartbeatController, + agentMessageController: runtimeSessionOptions?.agentMessageController, + agentObserveController: runtimeSessionOptions?.agentObserveController, rlmDepth: runtimeSessionOptions?.rlmDepth, rlmMaxDepth: runtimeSessionOptions?.rlmMaxDepth, rlmSessionDir: runtimeSessionOptions?.rlmSessionDir, diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index c26d997474..a2461b1a52 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -9,6 +9,41 @@ import { createServer, type Server, type Socket } from "node:net"; import { resolve } from "node:path"; import { appendRotatingLog, getCronJobsPath, getDaemonLogPath, VERSION } from "../../config.js"; +import { + AGENT_MESSAGE_SOURCE, + type AgentSessionMessageAgentSummary, + type AgentSessionMessageDeliveryStatus, + type AgentSessionMessageEndpoint, + type AgentSessionMessageListResult, + type AgentSessionMessagePayload, + AgentSessionMessageRateLimiter, + type AgentSessionMessageReceipt, + type AgentSessionMessageSender, + assertAgentMessageQueueCapacity, + assertDirectAgentMessageTarget, + createAgentSessionMessageId, + createAgentSessionMessagePrompt, + createAgentSessionMessageReceipt, + DEFAULT_AGENT_MESSAGE_MAX_CHARS, + DEFAULT_AGENT_MESSAGE_MAX_PENDING_PER_SESSION, + DEFAULT_AGENT_MESSAGE_RATE_LIMIT_CAPACITY, + DEFAULT_AGENT_MESSAGE_RATE_LIMIT_REFILL_MS, + isAgentSessionMessagePrompt, + normalizeAgentSessionMessage, + resolveAgentSessionMessageStreamingBehavior, +} from "../../core/agent-messages.js"; +import { + type AgentObserveAgentSnapshot, + type AgentObserveAgentSummary, + type AgentObserveController, + type AgentObserveListResult, + type AgentObserveRecentMessagesInput, + type AgentObserveRecentMessagesResult, + createAgentObserveMessagePreview, + normalizeObserveLimit, + normalizeObserveMaxChars, +} from "../../core/agent-observe.js"; +import type { PromptOptions } from "../../core/agent-session.js"; import { type AgentSessionRuntimeConfig, mergeAgentSessionRuntimeConfig } from "../../core/agent-session-config.js"; import { AgentSessionRuntime, @@ -103,6 +138,11 @@ const DAEMON_COMMAND_TYPES: ReadonlySet = new Set([ "prompt", "steer", "follow_up", + "send_message", + "agent_messages_status", + "agent_messages_pause", + "agent_messages_resume", + "agent_messages_clear", "abort", "execute_bash", "abort_bash", @@ -187,6 +227,17 @@ export class AgentDaemon { private readonly signalCleanupHandlers: Array<() => void> = []; private readonly cronStore: AgentCronJobStore; private readonly cronScheduler: AgentCronScheduler; + private readonly agentMessageRateLimiter = new AgentSessionMessageRateLimiter(); + private readonly agentMessagePendingReservations = new Map(); + private readonly agentMessageTargetLocks = new Map>(); + private readonly agentMessageAcceptingTargets = new Set(); + // Refcount of prompts in preflight (accepted but not yet streaming); >0 makes + // concurrent agent messages queue instead of racing agent.prompt. + private readonly agentMessagePreparingTargets = new Map(); + // Sessions inserted into `sessions` but still awaiting extension binding; + // visible to host controllers during bind, excluded from targeting. + private readonly bindingSessions = new Set(); + private agentMessagesPaused = false; private readonly summarizer = new DaemonSessionSummarizer( () => [...this.sessions.values()], (state) => { @@ -293,7 +344,11 @@ export class AgentDaemon { cleanupDaemonSocketPath(this.socketPath); } - private async addRuntime(runtime: AgentSessionRuntime, name?: string): Promise { + private async addRuntime( + runtime: AgentSessionRuntime, + name?: string, + onStateCreated?: (state: ActiveSessionState) => void, + ): Promise { const state: ActiveSessionState = { activeSessionId: createActiveSessionId(this.sessions), runtime, @@ -301,6 +356,12 @@ export class AgentDaemon { extensionUiRequests: new Map(), lastEventSequence: 0, }; + if (name) { + state.runtime.session.setSessionName(name); + } + this.sessions.set(state.activeSessionId, state); + this.bindingSessions.add(state.activeSessionId); + onStateCreated?.(state); try { await bindActiveSessionState(state, { broadcast: (targetSessionState, message) => this.broadcastToSession(targetSessionState, message), @@ -311,13 +372,22 @@ export class AgentDaemon { }); } catch (error) { state.unsubscribe?.(); + this.sessions.delete(state.activeSessionId); await runtime.dispose().catch(() => undefined); throw error; + } finally { + this.bindingSessions.delete(state.activeSessionId); } - this.sessions.set(state.activeSessionId, state); this.rebindCronJobsToState(state); - if (name) { - state.runtime.session.setSessionName(name); + if (runtime.metadata.kind !== "subagent") { + // Mark the session as daemon-resident so a restarted daemon can + // restore it. Closes for kill/completed/replaced flip this back to + // sleep; clean shutdowns leave it in place on purpose. + try { + runtime.session.sessionManager.appendSessionState({ status: "active" }); + } catch { + // Marking is best-effort; the session still works unrestored. + } } // Restore the last persisted status so it shows before the first sweep. this.summarizer.seed(state); @@ -398,11 +468,32 @@ export class AgentDaemon { return this.deleteRlmHeartbeatForState(stateRef, id); }, }, + agentMessageController: { + listAgents: () => { + if (!stateRef) { + throw new Error("Agent message state is not ready for this session yet"); + } + return this.createAgentMessageListResult(stateRef); + }, + sendAgentMessage: (input) => { + if (!stateRef) { + throw new Error("Agent message state is not ready for this session yet"); + } + return this.sendAgentSessionMessage({ + targetSelector: input.target, + message: input.message, + fromState: stateRef, + deliveryMode: input.deliveryMode, + origin: "agent", + }); + }, + }, + agentObserveController: this.createAgentObserveController(() => stateRef), }, }); - const state = await this.addRuntime(runtime, command.name); - stateRef = state; - return state; + return this.addRuntime(runtime, command.name, (state) => { + stateRef = state; + }); }; const sessionFile = sessionManager.getSessionFile(); @@ -433,25 +524,78 @@ export class AgentDaemon { private async runCronJob(job: AgentCronJob): Promise<"skipped" | undefined> { const state = await this.getOrCreateCronJobSession(job); if (!state) { - return; + return "skipped"; } - if (shouldDeferHeartbeatCronJob(job, state.runtime.session)) { + const session = state.runtime.session; + const isAgentMessagePromptInProgress = + this.agentMessageAcceptingTargets.has(state.activeSessionId) || + this.agentMessagePreparingTargets.has(state.activeSessionId); + if (isHeartbeatCronJob(job) && isAgentMessagePromptInProgress) { return "skipped"; } - if ( - !isHeartbeatCronJob(job) && - (state.runtime.session.isStreaming || state.runtime.session.pendingMessageCount > 0) - ) { - await state.runtime.session.followUp(job.prompt); + if (shouldDeferHeartbeatCronJob(job, session)) { + return "skipped"; + } + const shouldQueueCronPrompt = + isAgentMessagePromptInProgress || + session.isStreaming || + session.isCompacting || + session.isRetrying || + session.isBashRunning || + session.hasAcceptedPromptInFlight || + session.pendingMessageCount > 0; + if (!isHeartbeatCronJob(job) && shouldQueueCronPrompt) { + await session.followUp(job.prompt); return; } - await state.runtime.session.prompt(job.prompt, { + await this.promptWithAgentMessagePreparingGuard(state, job.prompt, { streamingBehavior: "followUp", followUpQueueKey: isHeartbeatCronJob(job) ? `heartbeat:${job.id}` : undefined, source: "rpc", }); } + // Wraps session.prompt so the target counts as "preparing" until the turn + // starts streaming (or the prompt settles); concurrent agent messages queue + // instead of racing agent.prompt during the preflight awaits. Refcounted so + // overlapping prompts each hold their own registration. + private async promptWithAgentMessagePreparingGuard( + state: ActiveSessionState, + message: string, + options?: PromptOptions, + ): Promise { + const activeSessionId = state.activeSessionId; + const session = state.runtime.session; + this.agentMessagePreparingTargets.set( + activeSessionId, + (this.agentMessagePreparingTargets.get(activeSessionId) ?? 0) + 1, + ); + let cleared = false; + const clearPreparing = () => { + if (cleared) { + return; + } + cleared = true; + const next = (this.agentMessagePreparingTargets.get(activeSessionId) ?? 1) - 1; + if (next <= 0) { + this.agentMessagePreparingTargets.delete(activeSessionId); + } else { + this.agentMessagePreparingTargets.set(activeSessionId, next); + } + }; + try { + await this.agentMessageTargetLocks.get(activeSessionId)?.catch(() => undefined); + await session.prompt(message, { + ...options, + preflightResult: (didSucceed) => { + options?.preflightResult?.(didSucceed); + }, + }); + } finally { + clearPreparing(); + } + } + private createCronJobForState(state: ActiveSessionState, schedule: string, prompt: string): AgentCronJob { const session = state.runtime.session; const sessionFile = session.sessionFile; @@ -600,11 +744,16 @@ export class AgentDaemon { private async getOrCreateCronJobSession(job: AgentCronJob): Promise { const current = this.sessions.get(job.activeSessionId) ?? this.findSessionBySessionFile(job.sessionFile); - if (current) { + // A half-bound match falls through to createRuntime, which awaits the + // pending create for the same session file instead of prompting mid-bind. + if (current && !this.bindingSessions.has(current.activeSessionId)) { this.rebindCronJobsToState(current); return current; } if (job.source === "rlm_heartbeat" && job.runtimeKind === "subagent") { + if (current && this.bindingSessions.has(current.activeSessionId)) { + return undefined; + } this.cronStore.cancel(job.id); this.cronScheduler.wake(); return undefined; @@ -630,6 +779,16 @@ export class AgentDaemon { return resolveActiveSessionState(this.sessions, id); } + // A bind failure disposes the runtime, so half-bound sessions must not be + // targetable by attach, agent messages, or observe. + private getBoundSessionState(id: string): ActiveSessionState { + const state = this.getSessionState(id); + if (this.bindingSessions.has(state.activeSessionId)) { + throw new Error(`Active session ${state.activeSessionId} is still initializing`); + } + return state; + } + private findRuntimeState(runtime: RlmSubagentRuntime): ActiveSessionState | undefined { if (!(runtime instanceof AgentSessionRuntime)) { return undefined; @@ -745,11 +904,110 @@ export class AgentDaemon { sessionDir: options.sessionDir, }, }); - const state = await this.addRuntime(runtime); - stateRef = state; + await this.addRuntime(runtime, undefined, (state) => { + stateRef = state; + }); return runtime; } + private createAgentObserveController(getCurrentState: () => ActiveSessionState | undefined): AgentObserveController { + const requireCurrentState = () => { + const current = getCurrentState(); + if (!current) { + throw new Error("Agent observe state is not ready for this session yet"); + } + return current; + }; + return { + listAgents: () => this.createAgentObserveListResult(requireCurrentState()), + getAgent: (target) => this.createAgentObserveAgentSnapshot(requireCurrentState(), target), + recentMessages: (input) => this.createAgentObserveRecentMessages(requireCurrentState(), input), + }; + } + + private createAgentObserveListResult(currentState: ActiveSessionState): AgentObserveListResult { + return { + current: this.createAgentObserveSummary(currentState, currentState), + agents: this.listTargetableSessionStates(currentState).map((state) => + this.createAgentObserveSummary(state, currentState), + ), + }; + } + + private createAgentObserveAgentSnapshot( + currentState: ActiveSessionState, + target: string, + ): AgentObserveAgentSnapshot { + return { + agent: this.createAgentObserveSummary(this.getBoundSessionState(target), currentState), + }; + } + + private createAgentObserveRecentMessages( + currentState: ActiveSessionState, + input: AgentObserveRecentMessagesInput, + ): AgentObserveRecentMessagesResult { + const targetState = this.getBoundSessionState(input.target); + const limit = normalizeObserveLimit(input.limit); + const maxChars = normalizeObserveMaxChars(input.maxChars); + const messages = targetState.runtime.session.messages; + const startIndex = Math.max(0, messages.length - limit); + return { + agent: this.createAgentObserveSummary(targetState, currentState), + messages: messages + .slice(startIndex) + .map((message, offset) => createAgentObserveMessagePreview(message, startIndex + offset, maxChars)), + limit, + maxChars, + truncated: startIndex > 0, + }; + } + + private createAgentObserveSummary( + state: ActiveSessionState, + currentState: ActiveSessionState, + ): AgentObserveAgentSummary { + const summary = summaryForActiveSession(state); + const session = state.runtime.session; + const messages = session.messages; + const latest = messages.at(-1); + const status = session.isStreaming + ? session.state.pendingToolCalls.size > 0 + ? "tool" + : "model" + : session.isCompacting + ? "compacting" + : session.isRetrying || + session.isBashRunning || + session.hasAcceptedPromptInFlight || + session.pendingMessageCount > 0 || + session.hasRunningRlmChildren() + ? "busy" + : state.clients.size > 0 + ? "user" + : "idle"; + return { + activeSessionId: state.activeSessionId, + sessionId: summary.sessionId, + ...(summary.sessionName ? { sessionName: summary.sessionName } : {}), + ...(summary.runtimeKind ? { runtimeKind: summary.runtimeKind } : {}), + cwd: summary.cwd, + status, + isCurrent: state.activeSessionId === currentState.activeSessionId, + isStreaming: summary.isStreaming, + isCompacting: summary.isCompacting, + attachedClients: summary.attachedClients, + messageCount: summary.messageCount, + pendingMessageCount: summary.pendingMessageCount, + ...(summary.parentActiveSessionId ? { parentActiveSessionId: summary.parentActiveSessionId } : {}), + ...(summary.parentSessionId ? { parentSessionId: summary.parentSessionId } : {}), + ...(summary.rlmChildId ? { rlmChildId: summary.rlmChildId } : {}), + ...(summary.rlmParentNodeId ? { rlmParentNodeId: summary.rlmParentNodeId } : {}), + ...(summary.firstMessage ? { firstMessage: summary.firstMessage } : {}), + ...(latest ? { latestMessage: createAgentObserveMessagePreview(latest, messages.length - 1, 240) } : {}), + }; + } + private handleConnection(socket: Socket): void { const client: DaemonSocketClient = { id: createActiveSessionId(), @@ -880,7 +1138,7 @@ export class AgentDaemon { } case "attach": { - const state = this.getSessionState(command.activeSessionId); + const state = this.getBoundSessionState(command.activeSessionId); if (command.clientId) { client.id = command.clientId; } @@ -967,17 +1225,16 @@ export class AgentDaemon { responseSent = true; this.write(client, success(command.id, "prompt")); }; - void state.runtime.session - .prompt(command.message, { - images: command.images, - streamingBehavior: command.streamingBehavior, - source: "rpc", - preflightResult: (didSucceed) => { - if (didSucceed) { - sendSuccessResponse(); - } - }, - }) + void this.promptWithAgentMessagePreparingGuard(state, command.message, { + images: command.images, + streamingBehavior: command.streamingBehavior, + source: "rpc", + preflightResult: (didSucceed) => { + if (didSucceed) { + sendSuccessResponse(); + } + }, + }) .then(() => { sendSuccessResponse(); }) @@ -1003,6 +1260,47 @@ export class AgentDaemon { return success(command.id, "follow_up"); } + case "send_message": { + const fromState = command.fromActiveSessionId + ? this.getSessionState(command.fromActiveSessionId) + : undefined; + const receipt = await this.sendAgentSessionMessage({ + targetSelector: command.targetActiveSessionId, + message: command.message, + fromState, + clientId: client.id, + senderKey: this.createCliAgentMessageSenderKey(), + deliveryMode: command.deliveryMode, + origin: "cli", + }); + return success(command.id, "send_message", receipt); + } + + case "agent_messages_status": { + return success(command.id, "agent_messages_status", this.getAgentMessageSafetyStatus()); + } + + case "agent_messages_pause": { + this.agentMessagesPaused = true; + this.agentMessageRateLimiter.clear(); + await this.clearQueuedAgentSessionMessagesForAllStates(); + return success(command.id, "agent_messages_pause", this.getAgentMessageSafetyStatus()); + } + + case "agent_messages_resume": { + this.agentMessagesPaused = false; + return success(command.id, "agent_messages_resume", this.getAgentMessageSafetyStatus()); + } + + case "agent_messages_clear": { + const state = this.getSessionState(command.activeSessionId); + const cleared = await this.withAgentMessageTargetLock(state.activeSessionId, async () => { + this.agentMessageRateLimiter.clearMatching((key) => key.endsWith(`->${state.activeSessionId}`)); + return state.runtime.session.clearQueuedUserMessagesMatching(isAgentSessionMessagePrompt); + }); + return success(command.id, "agent_messages_clear", cleared); + } + case "abort": { const state = this.getSessionState(command.activeSessionId); await state.runtime.session.abort(); @@ -1464,6 +1762,255 @@ export class AgentDaemon { }; } + private createAgentSessionMessageEndpoint(state: ActiveSessionState): AgentSessionMessageEndpoint { + const metadata = state.runtime.metadata; + return { + activeSessionId: state.activeSessionId, + sessionId: state.runtime.session.sessionId, + ...(state.runtime.session.sessionName ? { sessionName: state.runtime.session.sessionName } : {}), + runtimeKind: metadata.kind, + }; + } + + private createAgentSessionMessageSender( + state: ActiveSessionState | undefined, + clientId: string, + ): AgentSessionMessageSender { + if (!state) { + return { clientId }; + } + return { + ...this.createAgentSessionMessageEndpoint(state), + clientId, + }; + } + + private createAgentMessageAgentSummary(state: ActiveSessionState): AgentSessionMessageAgentSummary { + const metadata = state.runtime.metadata; + return { + ...this.createAgentSessionMessageEndpoint(state), + cwd: state.runtime.cwd, + isStreaming: state.runtime.session.isStreaming, + pendingMessageCount: + state.runtime.session.pendingMessageCount + (state.runtime.session.hasAcceptedPromptInFlight ? 1 : 0), + ...(metadata.parentActiveSessionId ? { parentActiveSessionId: metadata.parentActiveSessionId } : {}), + ...(metadata.rlmChildId ? { rlmChildId: metadata.rlmChildId } : {}), + }; + } + + private createAgentMessageListResult(current: ActiveSessionState): AgentSessionMessageListResult { + return { + current: this.createAgentSessionMessageEndpoint(current), + agents: this.listTargetableSessionStates(current).map((state) => this.createAgentMessageAgentSummary(state)), + }; + } + + // Half-bound sessions are hidden from other sessions' listings; the current + // session stays visible to itself (controllers run during its own bind). + private listTargetableSessionStates(current: ActiveSessionState): ActiveSessionState[] { + return [...this.sessions.values()].filter( + (state) => + state.activeSessionId === current.activeSessionId || !this.bindingSessions.has(state.activeSessionId), + ); + } + + private getAgentMessageSafetyStatus() { + return { + paused: this.agentMessagesPaused, + maxMessageChars: DEFAULT_AGENT_MESSAGE_MAX_CHARS, + maxPendingPerSession: DEFAULT_AGENT_MESSAGE_MAX_PENDING_PER_SESSION, + rateLimitCapacity: DEFAULT_AGENT_MESSAGE_RATE_LIMIT_CAPACITY, + rateLimitRefillMs: DEFAULT_AGENT_MESSAGE_RATE_LIMIT_REFILL_MS, + }; + } + + private createCliAgentMessageSenderKey(): string { + return `cli:${this.socketPath}`; + } + + private async clearQueuedAgentSessionMessagesForState(state: ActiveSessionState) { + return this.withAgentMessageTargetLock(state.activeSessionId, async () => + state.runtime.session.clearQueuedUserMessagesMatching(isAgentSessionMessagePrompt), + ); + } + + private async clearQueuedAgentSessionMessagesForAllStates(): Promise { + await Promise.all( + [...this.sessions.values()].map((state) => this.clearQueuedAgentSessionMessagesForState(state)), + ); + } + + private reserveAgentMessageQueueSlot(targetState: ActiveSessionState): () => void { + const activeSessionId = targetState.activeSessionId; + const reserved = this.agentMessagePendingReservations.get(activeSessionId) ?? 0; + assertAgentMessageQueueCapacity( + targetState.runtime.session.pendingMessageCount + + (targetState.runtime.session.hasAcceptedPromptInFlight ? 1 : 0) + + reserved, + DEFAULT_AGENT_MESSAGE_MAX_PENDING_PER_SESSION, + ); + this.agentMessagePendingReservations.set(activeSessionId, reserved + 1); + let released = false; + return () => { + if (released) { + return; + } + released = true; + const next = (this.agentMessagePendingReservations.get(activeSessionId) ?? 1) - 1; + if (next <= 0) { + this.agentMessagePendingReservations.delete(activeSessionId); + } else { + this.agentMessagePendingReservations.set(activeSessionId, next); + } + }; + } + + private async withAgentMessageTargetLock(activeSessionId: string, run: () => Promise): Promise { + const previous = this.agentMessageTargetLocks.get(activeSessionId) ?? Promise.resolve(); + let releaseCurrent: () => void = () => {}; + const current = new Promise((resolve) => { + releaseCurrent = resolve; + }); + const next = previous.catch(() => undefined).then(() => current); + this.agentMessageTargetLocks.set(activeSessionId, next); + await previous.catch(() => undefined); + try { + return await run(); + } finally { + releaseCurrent(); + if (this.agentMessageTargetLocks.get(activeSessionId) === next) { + this.agentMessageTargetLocks.delete(activeSessionId); + } + } + } + + private async sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + clientId?: string; + senderKey?: string; + deliveryMode?: AgentSessionMessagePayload["deliveryMode"]; + origin: "agent" | "cli"; + }): Promise { + if (this.agentMessagesPaused) { + throw new Error("Agent messaging is paused"); + } + const targetSelector = assertDirectAgentMessageTarget(options.targetSelector); + const targetState = this.getBoundSessionState(targetSelector); + if (options.fromState?.activeSessionId === targetState.activeSessionId) { + throw new Error("Agent messaging cannot target the sending session"); + } + const message = normalizeAgentSessionMessage(options.message, DEFAULT_AGENT_MESSAGE_MAX_CHARS); + const releaseQueueSlot = this.reserveAgentMessageQueueSlot(targetState); + const senderKey = + options.senderKey ?? options.fromState?.activeSessionId ?? `client:${options.clientId ?? "unknown"}`; + const rateLimitKey = `${senderKey}->${targetState.activeSessionId}`; + const rateLimit = this.agentMessageRateLimiter.tryConsume(rateLimitKey); + if (!rateLimit.ok) { + releaseQueueSlot(); + throw new Error(`Agent messaging rate limit exceeded; retry after ${rateLimit.retryAfterMs}ms`); + } + const payload: AgentSessionMessagePayload = { + id: createAgentSessionMessageId(), + source: AGENT_MESSAGE_SOURCE, + message, + from: this.createAgentSessionMessageSender(options.fromState, options.clientId ?? options.origin), + target: this.createAgentSessionMessageEndpoint(targetState), + deliveryMode: options.deliveryMode ?? "auto", + }; + try { + const { status } = await this.withAgentMessageTargetLock(targetState.activeSessionId, async () => { + if (this.agentMessagesPaused) { + throw new Error("Agent messaging is paused"); + } + if ( + !this.sessions.has(targetState.activeSessionId) || + this.closingSessions.has(targetState.activeSessionId) + ) { + throw new Error("Target session is closing before agent message delivery"); + } + if (targetState.runtime.session.sessionId !== payload.target.sessionId) { + throw new Error("Target session changed before agent message delivery"); + } + return this.acceptAgentSessionMessage(targetState, payload, releaseQueueSlot); + }); + return createAgentSessionMessageReceipt(payload, status); + } catch (error) { + this.agentMessageRateLimiter.refund(rateLimitKey); + throw error; + } finally { + // Error-path backstop; success paths release inside acceptAgentSessionMessage. + releaseQueueSlot(); + } + } + + private async acceptAgentSessionMessage( + targetState: ActiveSessionState, + payload: AgentSessionMessagePayload, + releaseReservation: () => void, + ): Promise<{ status: AgentSessionMessageDeliveryStatus }> { + const session = targetState.runtime.session; + const reserved = this.agentMessagePendingReservations.get(targetState.activeSessionId) ?? 0; + const otherReservations = Math.max(0, reserved - 1); + assertAgentMessageQueueCapacity( + session.pendingMessageCount + (session.hasAcceptedPromptInFlight ? 1 : 0) + otherReservations, + DEFAULT_AGENT_MESSAGE_MAX_PENDING_PER_SESSION, + ); + const shouldQueue = + this.agentMessageAcceptingTargets.has(targetState.activeSessionId) || + this.agentMessagePreparingTargets.has(targetState.activeSessionId) || + session.isStreaming || + session.isCompacting || + session.isRetrying || + session.isBashRunning || + session.hasAcceptedPromptInFlight || + session.pendingMessageCount > 0; + const streamingBehavior = + resolveAgentSessionMessageStreamingBehavior(shouldQueue, payload.deliveryMode) ?? + (payload.deliveryMode === "steer" ? "steer" : "followUp"); + const prompt = createAgentSessionMessagePrompt(payload); + + if (shouldQueue) { + const didQueue = await session.queueAgentMessagePrompt(prompt, streamingBehavior); + if (!didQueue) { + throw new Error("Agent message was not queued"); + } + // The queued message now counts in pendingMessageCount. + releaseReservation(); + // Do not await delivery: a queued message delivers only when the target's + // turn progresses, and the sender is blocked inside its own turn — awaiting + // here deadlocks mutual sends between busy sessions. + return { status: "queued" }; + } + + this.agentMessageAcceptingTargets.add(targetState.activeSessionId); + let preflightFailed = false; + let preflightQueued = false; + try { + const acceptPrompt = + typeof session.acceptAgentMessagePrompt === "function" + ? session.acceptAgentMessagePrompt.bind(session) + : session.prompt.bind(session); + await acceptPrompt(prompt, { + expandPromptTemplates: false, + streamingBehavior, + queueIfBusy: true, + preflightResult: (didSucceed, didQueue) => { + preflightFailed = !didSucceed; + preflightQueued = didSucceed && didQueue === true; + }, + }); + if (preflightFailed) { + throw new Error("Agent message was not accepted"); + } + releaseReservation(); + return { status: preflightQueued ? "queued" : "delivered" }; + } finally { + this.agentMessageAcceptingTargets.delete(targetState.activeSessionId); + } + } + private detachClientFromSession(client: DaemonSocketClient, state: ActiveSessionState): void { detachClientFromActiveSession(client, state); this.write(client, { type: "session_detached", activeSessionId: state.activeSessionId }); diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index f53bba5160..fcdb9bc589 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -1,5 +1,10 @@ import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ImageContent, Transport } from "@earendil-works/pi-ai"; +import type { + AgentSessionMessageDeliveryMode, + AgentSessionMessageReceipt, + AgentSessionMessageSafetyStatus, +} from "../../core/agent-messages.js"; import type { AgentSessionRuntimeConfig } from "../../core/agent-session-config.js"; import type { AgentCronJob, AgentHeartbeatUpdateAction } from "../../core/cron-jobs.js"; import type { SessionCwdIssue } from "../../core/session-cwd.js"; @@ -173,6 +178,18 @@ export type DaemonCommand = } | { id?: string; type: "steer"; activeSessionId: string; message: string; images?: ImageContent[] } | { id?: string; type: "follow_up"; activeSessionId: string; message: string; images?: ImageContent[] } + | { + id?: string; + type: "send_message"; + targetActiveSessionId: string; + message: string; + fromActiveSessionId?: string; + deliveryMode?: AgentSessionMessageDeliveryMode; + } + | { id?: string; type: "agent_messages_status" } + | { id?: string; type: "agent_messages_pause" } + | { id?: string; type: "agent_messages_resume" } + | { id?: string; type: "agent_messages_clear"; activeSessionId: string } | { id?: string; type: "abort"; activeSessionId: string } | { id?: string; @@ -318,6 +335,8 @@ export type DaemonDeleteSavedSessionResult = DeleteSessionFileResult; export type DaemonResourceSnapshot = AgentConnectionResourceSnapshot; export type DaemonCronJob = AgentCronJob; +export type DaemonAgentSessionMessageReceipt = AgentSessionMessageReceipt; +export type DaemonAgentSessionMessageSafetyStatus = AgentSessionMessageSafetyStatus; export type DaemonOutbound = | DaemonResponse diff --git a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts index 4ad7782ef2..63f7c4b5b9 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts @@ -114,8 +114,13 @@ export function buildSessionList( return entries; } +function effectivePendingMessageCount(session: ActiveSessionState["runtime"]["session"]): number { + return session.pendingMessageCount + (session.hasAcceptedPromptInFlight ? 1 : 0); +} + export function summaryForActiveSession(activeSession: ActiveSessionState, savedSession?: SessionInfo): SessionSummary { const session = activeSession.runtime.session; + const pendingMessageCount = effectivePendingMessageCount(session); const metadata = activeSession.runtime.metadata ?? { kind: "top-level" as const }; let modified = savedSession?.modified.toISOString(); if (!modified && session.sessionFile) { @@ -144,7 +149,7 @@ export function summaryForActiveSession(activeSession: ActiveSessionState, saved isRunningTools: session.isStreaming && session.state.pendingToolCalls.size > 0, attachedClients: activeSession.clients.size, messageCount: session.messages.length, - pendingMessageCount: session.pendingMessageCount, + pendingMessageCount, streamingMessage: session.state.streamingMessage, created: savedSession?.created.toISOString(), modified, @@ -266,7 +271,7 @@ function rlmChildSnapshotForActiveSession( const runStatus = metadata.rlmChildId ? parent?.runtime.session.getRlmChildRunStatus(metadata.rlmChildId) : undefined; - const status = runStatus ?? (session.isStreaming || session.pendingMessageCount > 0 ? "running" : "done"); + const status = runStatus ?? (session.isStreaming || effectivePendingMessageCount(session) > 0 ? "running" : "done"); return { id: metadata.rlmChildId ?? activeSession.activeSessionId, parentId: parentNodeId, @@ -317,7 +322,10 @@ export function isActiveSessionBusy(activeSession: ActiveSessionState): boolean const session = activeSession.runtime.session; // Background subagents keep the parent "working" even after its own turn ends. return ( - session.isStreaming || session.isCompacting || session.pendingMessageCount > 0 || session.hasRunningRlmChildren() + session.isStreaming || + session.isCompacting || + effectivePendingMessageCount(session) > 0 || + session.hasRunningRlmChildren() ); } diff --git a/packages/coding-agent/test/agent-observe.test.ts b/packages/coding-agent/test/agent-observe.test.ts new file mode 100644 index 0000000000..6c11f61a9d --- /dev/null +++ b/packages/coding-agent/test/agent-observe.test.ts @@ -0,0 +1,61 @@ +import { fauxAssistantMessage, fauxThinking, fauxToolCall } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import { + createAgentObserveMessagePreview, + normalizeObserveLimit, + normalizeObserveMaxChars, +} from "../src/core/agent-observe.js"; + +describe("agent observe helpers", () => { + it("creates bounded text previews", () => { + const preview = createAgentObserveMessagePreview( + { + role: "user", + content: [{ type: "text", text: "abcdefghijklmnopqrstuvwxyz" }], + timestamp: 123, + }, + 4, + 8, + ); + + expect(preview).toEqual({ + index: 4, + role: "user", + timestamp: 123, + text: "abcdefgh", + truncated: true, + }); + }); + + it("includes assistant tool call names without exposing arguments", () => { + const preview = createAgentObserveMessagePreview( + fauxAssistantMessage(fauxToolCall("bash", { command: "secret" }), { stopReason: "toolUse" }), + 2, + 200, + ); + + expect(preview.text).toBe("[tool_call:bash]"); + expect(preview.toolCalls).toEqual(["bash"]); + expect(preview.text).not.toContain("secret"); + }); + + it("includes assistant thinking text in previews", () => { + const preview = createAgentObserveMessagePreview( + fauxAssistantMessage(fauxThinking("working through the plan")), + 1, + 200, + ); + + expect(preview.text).toBe("working through the plan"); + expect(preview.truncated).toBe(false); + }); + + it("validates bounds", () => { + expect(normalizeObserveLimit(undefined)).toBe(8); + expect(normalizeObserveLimit(50)).toBe(50); + expect(() => normalizeObserveLimit(0)).toThrow("between 1 and 50"); + expect(normalizeObserveMaxChars(undefined)).toBe(800); + expect(normalizeObserveMaxChars(80)).toBe(80); + expect(() => normalizeObserveMaxChars(2_001)).toThrow("between 80 and 2000"); + }); +}); diff --git a/packages/coding-agent/test/agent-session-bus.test.ts b/packages/coding-agent/test/agent-session-bus.test.ts new file mode 100644 index 0000000000..1b7d79f455 --- /dev/null +++ b/packages/coding-agent/test/agent-session-bus.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { + AGENT_MESSAGE_SOURCE, + AgentSessionMessageRateLimiter, + assertAgentMessageQueueCapacity, + assertDirectAgentMessageTarget, + createAgentSessionMessagePrompt, + createAgentSessionMessageReceipt, + normalizeAgentSessionMessage, + parseAgentSessionMessagePromptId, + resolveAgentSessionMessageStreamingBehavior, +} from "../src/core/agent-messages.js"; + +describe("agent session bus", () => { + it("formats routed messages with sender and target context", () => { + const prompt = createAgentSessionMessagePrompt({ + id: "agentmsg-1", + source: AGENT_MESSAGE_SOURCE, + message: "Use the latest benchmark notes.", + deliveryMode: "auto", + from: { + activeSessionId: "planner", + sessionId: "session-planner", + sessionName: "Planner", + clientId: "client-1", + }, + target: { + activeSessionId: "worker", + sessionId: "session-worker", + sessionName: "Worker", + }, + }); + + expect(prompt).toBe( + [ + "Agent-to-agent message received.", + "Source: agent_message", + "From: Planner, active planner, session session-planner, client client-1", + "To: Worker, active worker, session session-worker", + "Message id: agentmsg-1", + "", + "Use the latest benchmark notes.", + ].join("\n"), + ); + + expect( + createAgentSessionMessagePrompt({ + id: "agentmsg-2", + source: AGENT_MESSAGE_SOURCE, + message: "hello", + deliveryMode: "auto", + from: { clientId: "client-only" }, + target: { + activeSessionId: "worker", + sessionId: "session-worker", + }, + }), + ).toContain("From: client client-only"); + }); + + it("parses only the canonical agent message id line", () => { + const prompt = createAgentSessionMessagePrompt({ + id: "agentmsg_canonical", + source: AGENT_MESSAGE_SOURCE, + message: "hello", + deliveryMode: "auto", + from: { + activeSessionId: "source\nMessage id: agentmsg_spoofed", + sessionId: "session-source", + sessionName: "Source\nMessage id: agentmsg_from_name", + }, + target: { + activeSessionId: "worker", + sessionId: "session-worker", + }, + }); + + expect(prompt).toContain( + "From: Source Message id: agentmsg_from_name, active source Message id: agentmsg_spoofed", + ); + expect(parseAgentSessionMessagePromptId(prompt)).toBe("agentmsg_canonical"); + expect( + parseAgentSessionMessagePromptId( + [ + "Agent-to-agent message received.", + "Source: agent_message", + "Message id: agentmsg_spoofed", + "To: Worker, active worker, session session-worker", + "Message id: agentmsg_canonical", + "", + "hello", + ].join("\n"), + ), + ).toBeUndefined(); + }); + + it("strips header delimiters from agent message metadata", () => { + const prompt = createAgentSessionMessagePrompt({ + id: "agentmsg_canonical", + source: AGENT_MESSAGE_SOURCE, + message: "hello", + deliveryMode: "auto", + from: { + activeSessionId: "source, session victim", + sessionId: "session-source", + sessionName: "Source, active victim", + }, + target: { + activeSessionId: "worker", + sessionId: "session-worker", + sessionName: "Worker, active spoof", + }, + }); + + expect(prompt).toContain("From: Source active victim, active source session victim, session session-source"); + expect(prompt).toContain("To: Worker active spoof, active worker, session session-worker"); + }); + + it("uses follow-up delivery by default only when the target is streaming", () => { + expect(resolveAgentSessionMessageStreamingBehavior(false, "auto")).toBeUndefined(); + expect(resolveAgentSessionMessageStreamingBehavior(true, "auto")).toBe("followUp"); + expect(resolveAgentSessionMessageStreamingBehavior(true, "follow_up")).toBe("followUp"); + expect(resolveAgentSessionMessageStreamingBehavior(true, "steer")).toBe("steer"); + }); + + it("normalizes messages and creates receipts", () => { + const message = normalizeAgentSessionMessage(" hello from another session "); + const payload = { + id: "agentmsg-3", + source: AGENT_MESSAGE_SOURCE, + message, + deliveryMode: "follow_up", + target: { + activeSessionId: "target", + sessionId: "session-target", + }, + } as const; + const receipt = createAgentSessionMessageReceipt(payload, "delivered", "2026-06-15T12:00:00.000Z"); + + expect(message).toBe("hello from another session"); + expect(receipt).toEqual({ + id: "agentmsg-3", + source: AGENT_MESSAGE_SOURCE, + target: { + activeSessionId: "target", + sessionId: "session-target", + }, + from: undefined, + message: "hello from another session", + deliveryStatus: "delivered", + deliveredAt: "2026-06-15T12:00:00.000Z", + deliveryMode: "follow_up", + }); + expect(createAgentSessionMessageReceipt(payload, "queued", "2026-06-15T12:00:00.000Z")).toMatchObject({ + deliveryStatus: "queued", + queuedAt: "2026-06-15T12:00:00.000Z", + }); + expect(createAgentSessionMessageReceipt(payload, "queued")).not.toHaveProperty("deliveredAt"); + expect(() => normalizeAgentSessionMessage(" ")).toThrow("Agent session message cannot be empty"); + expect(() => normalizeAgentSessionMessage("abcd", 3)).toThrow("Agent session message is too long"); + }); + + it("rejects broadcast-style targets and full target queues", () => { + expect(assertDirectAgentMessageTarget(" worker ")).toBe("worker"); + expect(() => assertDirectAgentMessageTarget("*")).toThrow("Broadcast agent messaging is not supported"); + expect(() => assertDirectAgentMessageTarget("all")).toThrow("Broadcast agent messaging is not supported"); + expect(() => assertAgentMessageQueueCapacity(20, 20)).toThrow("Target session has too many pending messages"); + expect(() => assertAgentMessageQueueCapacity(19, 20)).not.toThrow(); + }); + + it("rate limits senders with a token bucket", () => { + let now = 0; + const limiter = new AgentSessionMessageRateLimiter({ + capacity: 3, + refillMs: 1000, + now: () => now, + }); + + expect(limiter.tryConsume("sender")).toEqual({ ok: true }); + expect(limiter.tryConsume("sender")).toEqual({ ok: true }); + expect(limiter.tryConsume("sender")).toEqual({ ok: true }); + expect(limiter.tryConsume("sender")).toEqual({ ok: false, retryAfterMs: 1000 }); + + now = 1000; + expect(limiter.tryConsume("sender")).toEqual({ ok: true }); + expect(limiter.tryConsume("other")).toEqual({ ok: true }); + + limiter.refund("sender"); + expect(limiter.tryConsume("sender")).toEqual({ ok: true }); + + limiter.clear("sender"); + expect(limiter.tryConsume("sender")).toEqual({ ok: true }); + }); +}); diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 876883f212..de2d4e55c1 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -291,6 +291,68 @@ describe("AgentSession concurrent prompt guard", () => { expect(sawSteeringMessage).toBe(true); }); + it("delivers accepted agent messages without extension input interception", async () => { + const model = getModel("anthropic", "claude-sonnet-4-5")!; + let inputCalls = 0; + let receivedUserText: string | undefined; + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { + model, + systemPrompt: "Test", + tools: [], + }, + streamFn: (_model, context) => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + const userMessages = context.messages.filter((message) => message.role === "user"); + const user = userMessages.at(-1); + if (user && typeof user.content !== "string") { + receivedUserText = user.content + .filter( + (part): part is TextContent => + typeof part === "object" && part !== null && part.type === "text", + ) + .map((part) => part.text) + .join("\n"); + } + stream.push({ type: "start", partial: createAssistantMessage("") }); + stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Delivered") }); + }); + return stream; + }, + }); + + const sessionManager = SessionManager.inMemory(); + const settingsManager = SettingsManager.create(tempDir, tempDir); + const authStorage = AuthStorage.create(join(tempDir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, tempDir); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const extensionsResult = await createTestExtensionsResult([ + (pi) => { + pi.on("input", async () => { + inputCalls++; + return { action: "handled" }; + }); + }, + ]); + + session = new AgentSession({ + agent, + sessionManager, + settingsManager, + cwd: tempDir, + modelRegistry, + resourceLoader: createTestResourceLoader({ extensionsResult }), + }); + + await session.acceptAgentMessagePrompt("agent-to-agent payload", { expandPromptTemplates: false }); + await session.agent.waitForIdle(); + + expect(inputCalls).toBe(0); + expect(receivedUserText).toBe("agent-to-agent payload"); + }); + it("should allow prompt() after previous completes", async () => { // Create session with a stream that completes immediately const model = getModel("anthropic", "claude-sonnet-4-5")!; diff --git a/packages/coding-agent/test/agent-session-services.test.ts b/packages/coding-agent/test/agent-session-services.test.ts new file mode 100644 index 0000000000..3b1cc0b4ea --- /dev/null +++ b/packages/coding-agent/test/agent-session-services.test.ts @@ -0,0 +1,240 @@ +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { registerFauxProvider } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { AGENT_MESSAGE_SKILL_NAME, type AgentSessionMessageController } from "../src/core/agent-messages.js"; +import { + AGENT_OBSERVE_SKILL_NAME, + type AgentObserveController, + ORCHESTRATION_HEARTBEAT_SKILL_NAME, +} from "../src/core/agent-observe.js"; +import { createAgentSessionFromServices, createAgentSessionServices } from "../src/core/agent-session-services.js"; +import { AuthStorage } from "../src/core/auth-storage.js"; +import type { AgentRlmHeartbeatController } from "../src/core/cron-jobs.js"; +import { SessionManager } from "../src/core/session-manager.js"; +import { createSyntheticSourceInfo } from "../src/core/source-info.js"; + +describe("createAgentSessionFromServices", () => { + const cleanupPaths: string[] = []; + const unregisters: Array<() => void> = []; + + afterEach(() => { + while (unregisters.length > 0) { + unregisters.pop()?.(); + } + while (cleanupPaths.length > 0) { + const path = cleanupPaths.pop(); + if (path && existsSync(path)) { + rmSync(path, { recursive: true, force: true }); + } + } + }); + + it("forwards daemon-backed agent message controllers into AgentSession", async () => { + const tempDir = join(tmpdir(), `pi-session-services-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + cleanupPaths.push(tempDir); + + const faux = registerFauxProvider(); + unregisters.push(() => faux.unregister()); + + const authStorage = AuthStorage.inMemory(); + authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key"); + const services = await createAgentSessionServices({ + cwd: tempDir, + agentDir: tempDir, + authStorage, + resourceLoaderOptions: { + noPromptTemplates: true, + noThemes: true, + skillsOverride: () => ({ + skills: [ + { + name: AGENT_MESSAGE_SKILL_NAME, + description: "hidden agent message skill", + filePath: "", + baseDir: tempDir, + sourceInfo: createSyntheticSourceInfo("", { source: "test" }), + disableModelInvocation: true, + kind: "python" as const, + python: { + importName: "agent_message", + packagePath: tempDir, + pyprojectPath: join(tempDir, "pyproject.toml"), + }, + }, + ], + diagnostics: [], + }), + }, + }); + services.modelRegistry.registerProvider(faux.getModel().provider, { + baseUrl: faux.getModel().baseUrl, + apiKey: "faux-key", + api: faux.api, + models: faux.models, + }); + + const agentMessageController: AgentSessionMessageController = { + listAgents: () => ({ + current: { activeSessionId: "current", sessionId: "session-current", runtimeKind: "top-level" }, + agents: [ + { + activeSessionId: "worker", + sessionId: "session-worker", + runtimeKind: "top-level", + cwd: tempDir, + isStreaming: false, + pendingMessageCount: 0, + }, + ], + }), + sendAgentMessage: async () => { + throw new Error("not used"); + }, + }; + + const { session } = await createAgentSessionFromServices({ + services, + sessionManager: SessionManager.create(tempDir, join(tempDir, "sessions")), + model: faux.getModel(), + agentMessageController, + }); + + try { + expect(session.handleAgentMessageHostRequest("agent_message.list")).toMatchObject({ + current: { activeSessionId: "current" }, + agents: [{ activeSessionId: "worker" }], + }); + expect( + ( + session as unknown as { + _createKernelHostHandlers(): Record; + } + )._createKernelHostHandlers(), + ).not.toHaveProperty("agent_message.send"); + } finally { + session.dispose(); + } + }); + + it("hides daemon-backed orchestration skills unless their host bridges are available", async () => { + const tempDir = join(tmpdir(), `pi-session-skills-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + cleanupPaths.push(tempDir); + + const authStorage = AuthStorage.inMemory(); + const services = await createAgentSessionServices({ + cwd: tempDir, + agentDir: tempDir, + authStorage, + resourceLoaderOptions: { + noPromptTemplates: true, + noThemes: true, + }, + }); + + const createSession = async (options: Parameters[0]) => { + const { session } = await createAgentSessionFromServices(options); + return session; + }; + const visibleSkillNames = (session: unknown) => + ( + session as { + _modelVisibleSkills(): Array<{ name: string }>; + } + ) + ._modelVisibleSkills() + .map((skill) => skill.name); + const kernelHostHandlers = (session: unknown) => + ( + session as { + _createKernelHostHandlers(): Record; + } + )._createKernelHostHandlers(); + + const withoutControllers = await createSession({ + services, + sessionManager: SessionManager.create(tempDir, join(tempDir, "sessions-without")), + }); + try { + expect(visibleSkillNames(withoutControllers)).not.toContain(AGENT_MESSAGE_SKILL_NAME); + expect(visibleSkillNames(withoutControllers)).not.toContain(AGENT_OBSERVE_SKILL_NAME); + expect(visibleSkillNames(withoutControllers)).not.toContain(ORCHESTRATION_HEARTBEAT_SKILL_NAME); + } finally { + withoutControllers.dispose(); + } + + const agentObserveController: AgentObserveController = { + listAgents: () => ({ + current: { + activeSessionId: "current", + sessionId: "session-current", + runtimeKind: "top-level", + cwd: tempDir, + status: "idle", + isCurrent: true, + isStreaming: false, + isCompacting: false, + attachedClients: 1, + messageCount: 0, + pendingMessageCount: 0, + }, + agents: [], + }), + getAgent: () => { + throw new Error("not used"); + }, + recentMessages: () => { + throw new Error("not used"); + }, + }; + const rlmHeartbeatController: AgentRlmHeartbeatController = { + listRlmHeartbeats: () => [], + createRlmHeartbeat: () => { + throw new Error("not used"); + }, + updateRlmHeartbeat: () => { + throw new Error("not used"); + }, + deleteRlmHeartbeat: () => { + throw new Error("not used"); + }, + }; + const withControllers = await createSession({ + services, + sessionManager: SessionManager.create(tempDir, join(tempDir, "sessions-with")), + agentObserveController, + rlmHeartbeatController, + }); + try { + expect(visibleSkillNames(withControllers)).toContain(AGENT_OBSERVE_SKILL_NAME); + expect(visibleSkillNames(withControllers)).toContain(ORCHESTRATION_HEARTBEAT_SKILL_NAME); + expect(visibleSkillNames(withControllers)).not.toContain(AGENT_MESSAGE_SKILL_NAME); + } finally { + withControllers.dispose(); + } + + const agentMessageController: AgentSessionMessageController = { + listAgents: () => ({ + current: { activeSessionId: "current", sessionId: "session-current" }, + agents: [], + }), + sendAgentMessage: async () => { + throw new Error("not used"); + }, + }; + const withMessageController = await createSession({ + services, + sessionManager: SessionManager.create(tempDir, join(tempDir, "sessions-with-message")), + agentMessageController, + }); + try { + expect(visibleSkillNames(withMessageController)).toContain(AGENT_MESSAGE_SKILL_NAME); + expect(kernelHostHandlers(withMessageController)).toHaveProperty("agent_message.send"); + } finally { + withMessageController.dispose(); + } + }); +}); diff --git a/packages/coding-agent/test/builtin-skills.test.ts b/packages/coding-agent/test/builtin-skills.test.ts index 768ef09821..8144459dfe 100644 --- a/packages/coding-agent/test/builtin-skills.test.ts +++ b/packages/coding-agent/test/builtin-skills.test.ts @@ -245,6 +245,17 @@ describe("builtin skills", () => { expect(rlmHeartbeat?.kind === "python" && rlmHeartbeat.python.importName).toBe("rlm_heartbeat"); }); + it("loads the bundled orchestration heartbeat skill as a python skill", () => { + const { skills } = loadSkillsFromDir({ dir: getBundledSkillsDir(), source: "builtin" }); + + const orchestrationHeartbeat = skills.find((s) => s.name === "orchestration-heartbeat"); + expect(orchestrationHeartbeat).toBeDefined(); + expect(orchestrationHeartbeat?.kind).toBe("python"); + expect(orchestrationHeartbeat?.kind === "python" && orchestrationHeartbeat.python.importName).toBe( + "orchestration_heartbeat", + ); + }); + it("ships the edit skill as a python skill importable as `edit`", () => { const { skills } = loadSkillsFromDir({ dir: getBundledSkillsDir(), source: "builtin" }); diff --git a/packages/coding-agent/test/daemon-command.test.ts b/packages/coding-agent/test/daemon-command.test.ts index 8a40811932..746ba658fc 100644 --- a/packages/coding-agent/test/daemon-command.test.ts +++ b/packages/coding-agent/test/daemon-command.test.ts @@ -7,6 +7,10 @@ const daemonClientMock = vi.hoisted(() => { type: string; name?: string; activeSessionId?: string; + targetActiveSessionId?: string; + fromActiveSessionId?: string; + deliveryMode?: string; + message?: string; schedule?: string; prompt?: string; sessionPath?: string; @@ -225,6 +229,130 @@ describe("daemon command", () => { }); }); + it("honors send delivery-mode flags after the target", async () => { + await expect( + handleDaemonCommand([ + "daemon", + "--socket", + "/tmp/prime-agent.sock", + "send", + "worker", + "--steer", + "stop", + "and", + "re-plan", + ]), + ).resolves.toBe(true); + + const client = daemonClientMock.instances[0]; + expect(client?.requests[0]).toEqual({ + type: "send_message", + targetActiveSessionId: "worker", + fromActiveSessionId: undefined, + deliveryMode: "steer", + message: "stop and re-plan", + }); + }); + + it("rejects unknown send options instead of folding them into the message", async () => { + await expect( + handleDaemonCommand(["daemon", "--socket", "/tmp/prime-agent.sock", "send", "worker", "--bogus", "hello"]), + ).rejects.toThrow("exit 1"); + + expect(daemonClientMock.instances[0]?.requests).toEqual([]); + expect( + consoleErrorMessages.some( + (message) => typeof message === "string" && message.includes("Unknown option for daemon send: --bogus"), + ), + ).toBe(true); + }); + + it("supports send separator after the target for flag-like message text", async () => { + await expect( + handleDaemonCommand([ + "daemon", + "--socket", + "/tmp/prime-agent.sock", + "send", + "--follow-up", + "worker", + "--", + "--from", + "literal", + "--steer", + ]), + ).resolves.toBe(true); + + const client = daemonClientMock.instances[0]; + expect(client?.requests[0]).toEqual({ + type: "send_message", + targetActiveSessionId: "worker", + fromActiveSessionId: undefined, + deliveryMode: "follow_up", + message: "--from literal --steer", + }); + }); + + it("supports send separator before a flag-like target or message", async () => { + await expect( + handleDaemonCommand([ + "daemon", + "--socket", + "/tmp/prime-agent.sock", + "send", + "--", + "--target-like", + "--from", + "literal", + ]), + ).resolves.toBe(true); + + const client = daemonClientMock.instances[0]; + expect(client?.requests[0]).toMatchObject({ + type: "send_message", + targetActiveSessionId: "--target-like", + message: "--from literal", + }); + }); + + it("rejects extra agent-messages status arguments", async () => { + await expect( + handleDaemonCommand(["daemon", "--socket", "/tmp/prime-agent.sock", "agent-messages", "pause", "active-1"]), + ).rejects.toThrow("exit 1"); + + expect(daemonClientMock.instances[0]?.requests).toEqual([]); + expect( + consoleErrorMessages.some( + (message) => typeof message === "string" && message.includes("Usage: daemon agent-messages pause"), + ), + ).toBe(true); + }); + + it("parses send message text from an explicit --message value", async () => { + await expect( + handleDaemonCommand([ + "daemon", + "--socket", + "/tmp/prime-agent.sock", + "send", + "--from", + "planner", + "worker", + "--message", + "please keep --from literal --steer", + ]), + ).resolves.toBe(true); + + const client = daemonClientMock.instances[0]; + expect(client?.requests[0]).toEqual({ + type: "send_message", + targetActiveSessionId: "worker", + fromActiveSessionId: "planner", + deliveryMode: undefined, + message: "please keep --from literal --steer", + }); + }); + it("preserves cron add separator before the scheduled prompt", async () => { await expect( handleDaemonCommand([ diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 471c516936..2839812c48 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -70,6 +70,2026 @@ describe("daemon mode helpers", () => { expect(resolve).toHaveBeenCalledWith({ cancelled: true }); }); + it("acknowledges agent messages after target prompt preflight succeeds", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target") as ActiveSessionState & { + runtime: ActiveSessionState["runtime"] & { + session: { + sessionId: string; + sessionName: string; + isStreaming: boolean; + pendingMessageCount: number; + acceptAgentMessagePrompt: ReturnType; + }; + }; + }; + let resolvePrompt: () => void = () => {}; + const acceptAgentMessagePrompt = vi.fn( + (_message: string, options?: { preflightResult?: (didSucceed: boolean) => void }) => { + options?.preflightResult?.(true); + return new Promise((resolve) => { + resolvePrompt = resolve; + }); + }, + ); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + acceptAgentMessagePrompt, + }, + } as never; + fromState.runtime = { + ...fromState.runtime, + session: { + sessionId: "session-source", + sessionName: "Source", + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + const send = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "please continue", + fromState, + origin: "agent", + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(acceptAgentMessagePrompt).toHaveBeenCalledOnce(); + resolvePrompt(); + await expect(send).resolves.toMatchObject({ + deliveryStatus: "delivered", + target: { activeSessionId: targetState.activeSessionId }, + }); + }); + + it("reports queued status when a direct accept races into the queue", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + const acceptAgentMessagePrompt = vi.fn( + (_message: string, options?: { preflightResult?: (didSucceed: boolean, didQueue?: boolean) => void }) => { + options?.preflightResult?.(true, true); + return Promise.resolve(); + }, + ); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + acceptAgentMessagePrompt, + }, + } as never; + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "please continue", + fromState, + origin: "agent", + }), + ).resolves.toMatchObject({ + deliveryStatus: "queued", + target: { activeSessionId: targetState.activeSessionId }, + }); + }); + + it("rate limits agent messages per sender and target pair", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetA = makeState("target-a"); + const targetB = makeState("target-b"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + for (const targetState of [targetA, targetB]) { + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: `session-${targetState.activeSessionId}`, + sessionName: targetState.activeSessionId, + isStreaming: false, + pendingMessageCount: 0, + prompt: vi.fn(async () => {}), + followUp: vi.fn(async () => true), + clearQueue: vi.fn(() => ({ cleared: 0 })), + clearQueuedUserMessagesMatching: vi.fn(() => ({ steering: [], followUp: [] })), + }, + } as never; + } + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetA.activeSessionId, targetA); + internals.sessions.set(targetB.activeSessionId, targetB); + + for (let i = 0; i < 3; i++) { + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetA.activeSessionId, + message: `message ${i}`, + fromState, + origin: "agent", + }), + ).resolves.toMatchObject({ target: { activeSessionId: targetA.activeSessionId } }); + } + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetA.activeSessionId, + message: "over limit", + fromState, + origin: "agent", + }), + ).rejects.toThrow("Agent messaging rate limit exceeded"); + await internals.handleCommand(makeClient("client-1", targetA.activeSessionId), { + id: "command-1", + type: "agent_messages_clear", + activeSessionId: targetA.activeSessionId, + }); + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetA.activeSessionId, + message: "after clear", + fromState, + origin: "agent", + }), + ).resolves.toMatchObject({ target: { activeSessionId: targetA.activeSessionId } }); + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetB.activeSessionId, + message: "different target", + fromState, + origin: "agent", + }), + ).resolves.toMatchObject({ target: { activeSessionId: targetB.activeSessionId } }); + }); + + it("clears only queued agent-message prompts", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const targetState = makeState("target"); + const agentMessageText = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_test\n\nhello"; + const clearQueuedUserMessagesMatching = vi.fn((predicate: (text: string) => boolean) => ({ + steering: [agentMessageText].filter(predicate), + followUp: [], + })); + const clearQueue = vi.fn(() => ({ steering: ["user prompt"], followUp: ["heartbeat"] })); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 2, + clearQueuedUserMessagesMatching, + clearQueue, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + }; + internals.sessions.set(targetState.activeSessionId, targetState); + + await internals.handleCommand(makeClient("client-1", targetState.activeSessionId), { + id: "command-1", + type: "agent_messages_clear", + activeSessionId: targetState.activeSessionId, + }); + + expect(clearQueuedUserMessagesMatching).toHaveBeenCalledOnce(); + const predicate = clearQueuedUserMessagesMatching.mock.calls[0]?.[0]; + expect(predicate?.(agentMessageText)).toBe(true); + expect(predicate?.("ordinary queued follow-up")).toBe(false); + expect(clearQueue).not.toHaveBeenCalled(); + }); + + it("pause clears queued agent-message prompts from all sessions", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const firstState = makeState("target-1"); + const secondState = makeState("target-2"); + const firstClear = vi.fn(() => ({ steering: [], followUp: ["agent message"] })); + const secondClear = vi.fn(() => ({ steering: ["agent message"], followUp: [] })); + for (const [state, clearQueuedUserMessagesMatching] of [ + [firstState, firstClear], + [secondState, secondClear], + ] as const) { + state.runtime = { + ...state.runtime, + cwd: "/tmp", + session: { + sessionId: `session-${state.activeSessionId}`, + sessionName: state.activeSessionId, + isStreaming: false, + pendingMessageCount: 1, + clearQueuedUserMessagesMatching, + }, + } as never; + } + const internals = daemon as unknown as { + sessions: Map; + agentMessageRateLimiter: { clear: () => void }; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + }; + internals.agentMessageRateLimiter.clear = vi.fn(); + internals.sessions.set(firstState.activeSessionId, firstState); + internals.sessions.set(secondState.activeSessionId, secondState); + + await internals.handleCommand(makeClient("client-1", firstState.activeSessionId), { + id: "command-1", + type: "agent_messages_pause", + }); + + expect(internals.agentMessageRateLimiter.clear).toHaveBeenCalledOnce(); + expect(firstClear).toHaveBeenCalledOnce(); + expect(secondClear).toHaveBeenCalledOnce(); + }); + + it("pause clears queued agent messages concurrently across sessions", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const blockedState = makeState("blocked"); + const readyState = makeState("ready"); + let resolveBlockedClear: () => void = () => {}; + const blockedClear = vi.fn( + () => + new Promise<{ steering: string[]; followUp: string[] }>((resolve) => { + resolveBlockedClear = () => resolve({ steering: [], followUp: [] }); + }), + ); + const readyClear = vi.fn(() => ({ steering: [], followUp: ["agent message"] })); + for (const [state, clearQueuedUserMessagesMatching] of [ + [blockedState, blockedClear], + [readyState, readyClear], + ] as const) { + state.runtime = { + ...state.runtime, + cwd: "/tmp", + session: { + sessionId: `session-${state.activeSessionId}`, + sessionName: state.activeSessionId, + isStreaming: false, + pendingMessageCount: 1, + clearQueuedUserMessagesMatching, + }, + } as never; + } + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + }; + internals.sessions.set(blockedState.activeSessionId, blockedState); + internals.sessions.set(readyState.activeSessionId, readyState); + + const pause = internals.handleCommand(makeClient("client-1", blockedState.activeSessionId), { + id: "command-1", + type: "agent_messages_pause", + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(blockedClear).toHaveBeenCalledOnce(); + expect(readyClear).toHaveBeenCalledOnce(); + resolveBlockedClear(); + await pause; + }); + + it("refunds agent message rate limit tokens when delivery fails", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + prompt: vi.fn(async () => { + throw new Error("missing model"); + }), + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + for (let i = 0; i < 3; i++) { + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: `message ${i}`, + fromState, + origin: "agent", + }), + ).rejects.toThrow("missing model"); + } + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "after failed sends", + fromState, + origin: "agent", + }), + ).rejects.toThrow("missing model"); + }); + + it("counts concurrent agent message queue reservations against the target queue cap", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + let rejectQueuedMessage: (error: Error) => void = () => {}; + const queueAgentMessagePrompt = vi.fn( + (_message: string, _streamingBehavior: "steer" | "followUp") => + new Promise((_resolve, reject) => { + rejectQueuedMessage = reject; + }), + ); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: true, + pendingMessageCount: 19, + clearQueue: vi.fn(() => ({ cleared: 0 })), + clearQueuedUserMessagesMatching: vi.fn(() => ({ steering: [], followUp: [] })), + queueAgentMessagePrompt, + prompt: vi.fn(async () => {}), + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + const first = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "first", + fromState, + origin: "agent", + }); + await Promise.resolve(); + + const clear = internals.handleCommand(makeClient("client-1", targetState.activeSessionId), { + id: "command-1", + type: "agent_messages_clear", + activeSessionId: targetState.activeSessionId, + }); + await Promise.resolve(); + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "second", + fromState, + origin: "agent", + }), + ).rejects.toThrow("Target session has too many pending messages"); + + rejectQueuedMessage(new Error("release reservation")); + await expect(first).rejects.toThrow("release reservation"); + await clear; + }); + + it("releases queue reservations once messages are queued so concurrent senders do not halve capacity", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const targetState = makeState("target"); + let pending = 0; + const queueAgentMessagePrompt = vi.fn(async (_message: string, _streamingBehavior: "steer" | "followUp") => { + pending += 1; + return true; + }); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: true, + get pendingMessageCount() { + return pending; + }, + queueAgentMessagePrompt, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(targetState.activeSessionId, targetState); + // Distinct senders so the per-sender rate limit stays out of the way. + const senders = Array.from({ length: 12 }, (_, i) => { + const fromState = makeState(`source-${i}`); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: `session-source-${i}`, sessionName: `Source ${i}` }, + } as never; + internals.sessions.set(fromState.activeSessionId, fromState); + return fromState; + }); + + const errors: unknown[] = []; + for (const [i, fromState] of senders.entries()) { + void internals + .sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: `message ${i}`, + fromState, + origin: "agent", + }) + .catch((error) => { + errors.push(error); + }); + } + for (let attempt = 0; attempt < 200 && queueAgentMessagePrompt.mock.calls.length < 12; attempt++) { + await Promise.resolve(); + } + + // With reservations held past queue time, 12 concurrent senders would + // count as 24 against the 20-slot cap and the tail would reject. + expect(errors).toEqual([]); + expect(queueAgentMessagePrompt).toHaveBeenCalledTimes(12); + }); + + it("resolves queued sends immediately with a queued receipt while the target is streaming", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + const queueAgentMessagePrompt = vi.fn(async (_message: string, _streamingBehavior: "steer" | "followUp") => true); + // A real streaming session only resolves this once its turn progresses; + // the send must not depend on it. + const waitForAgentMessagePromptDelivery = vi.fn(() => new Promise(() => {})); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: true, + pendingMessageCount: 0, + queueAgentMessagePrompt, + waitForAgentMessagePromptDelivery, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "queued while streaming", + fromState, + origin: "agent", + }), + ).resolves.toMatchObject({ + deliveryStatus: "queued", + target: { activeSessionId: targetState.activeSessionId }, + }); + expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); + expect(waitForAgentMessagePromptDelivery).not.toHaveBeenCalled(); + }); + + it("resolves mutual sends between two busy sessions without deadlocking", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const makeBusyState = (name: string) => { + const state = makeState(name); + state.runtime = { + ...state.runtime, + cwd: "/tmp", + session: { + sessionId: `session-${name}`, + sessionName: name, + isStreaming: true, + pendingMessageCount: 0, + queueAgentMessagePrompt: vi.fn(async () => true), + // Neither turn ends while both sessions block inside their own send. + waitForAgentMessagePromptDelivery: vi.fn(() => new Promise(() => {})), + }, + } as never; + return state; + }; + const stateA = makeBusyState("alpha"); + const stateB = makeBusyState("beta"); + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(stateA.activeSessionId, stateA); + internals.sessions.set(stateB.activeSessionId, stateB); + + const [aToB, bToA] = await Promise.all([ + internals.sendAgentSessionMessage({ + targetSelector: stateB.activeSessionId, + message: "alpha to beta", + fromState: stateA, + origin: "agent", + }), + internals.sendAgentSessionMessage({ + targetSelector: stateA.activeSessionId, + message: "beta to alpha", + fromState: stateB, + origin: "agent", + }), + ]); + + expect(aToB).toMatchObject({ deliveryStatus: "queued", target: { activeSessionId: stateB.activeSessionId } }); + expect(bToA).toMatchObject({ deliveryStatus: "queued", target: { activeSessionId: stateA.activeSessionId } }); + }); + + it("counts accepted in-flight agent messages against the target queue cap", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + const acceptAgentMessagePrompt = vi.fn(async () => {}); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 19, + hasAcceptedPromptInFlight: true, + acceptAgentMessagePrompt, + queueAgentMessagePrompt: vi.fn(async () => true), + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "over cap", + fromState, + origin: "agent", + }), + ).rejects.toThrow("Target session has too many pending messages"); + expect(acceptAgentMessagePrompt).not.toHaveBeenCalled(); + }); + + it("reports accepted in-flight agent messages in agent-message lists", () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const targetState = makeState("target"); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 2, + hasAcceptedPromptInFlight: true, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + createAgentMessageListResult(current: ActiveSessionState): { agents: Array<{ pendingMessageCount: number }> }; + }; + internals.sessions.set(targetState.activeSessionId, targetState); + + expect(internals.createAgentMessageListResult(targetState).agents[0]?.pendingMessageCount).toBe(3); + }); + + it("reports non-streaming busy sessions as active in agent-observe summaries", () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const targetState = makeState("target"); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + diagnostics: [], + modelFallbackMessage: undefined, + session: { + sessionId: "session-target", + sessionName: "Target", + sessionFile: undefined, + sessionManager: { getCwd: () => "/tmp" }, + model: undefined, + thinkingLevel: "off", + isStreaming: false, + isCompacting: false, + isBashRunning: false, + isRetrying: false, + hasAcceptedPromptInFlight: false, + pendingMessageCount: 1, + messages: [], + state: { pendingToolCalls: new Set(), streamingMessage: undefined }, + hasRunningRlmChildren: () => false, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + createAgentObserveListResult(current: ActiveSessionState): { current: { status: string } }; + }; + internals.sessions.set(targetState.activeSessionId, targetState); + + expect(internals.createAgentObserveListResult(targetState).current.status).toBe("busy"); + + (targetState.runtime.session as { isCompacting: boolean; pendingMessageCount: number }).isCompacting = true; + (targetState.runtime.session as { isCompacting: boolean; pendingMessageCount: number }).pendingMessageCount = 0; + + expect(internals.createAgentObserveListResult(targetState).current.status).toBe("compacting"); + }); + + it("serializes concurrent agent messages to an idle target", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + const promptResolves: Array<() => void> = []; + const prompt = vi.fn( + (_message: string, _options?: { streamingBehavior?: "steer" | "followUp" }) => + new Promise((resolve) => { + promptResolves.push(resolve); + }), + ); + const followUp = vi.fn(async () => true); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + prompt, + followUp, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + const first = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "first", + fromState, + origin: "agent", + }); + const second = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "second", + fromState, + origin: "agent", + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(prompt).toHaveBeenCalledTimes(1); + + promptResolves[0]?.(); + await expect(first).resolves.toMatchObject({ message: "first" }); + await Promise.resolve(); + await Promise.resolve(); + + expect(prompt).toHaveBeenCalledTimes(2); + expect(followUp).not.toHaveBeenCalled(); + promptResolves[1]?.(); + await expect(second).resolves.toMatchObject({ message: "second" }); + }); + + it("queues agent messages behind an idle target with a pending retry", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + const prompt = vi.fn(async (_message: string, _options?: { streamingBehavior?: "steer" | "followUp" }) => {}); + const followUp = vi.fn(async () => true); + const queueAgentMessagePrompt = vi.fn(async (_message: string, _streamingBehavior: "steer" | "followUp") => true); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + isRetrying: true, + pendingMessageCount: 0, + prompt, + followUp, + queueAgentMessagePrompt, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "queued behind retry", + fromState, + origin: "agent", + }), + ).resolves.toMatchObject({ target: { activeSessionId: targetState.activeSessionId } }); + + expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); + expect(queueAgentMessagePrompt.mock.calls[0]?.[1]).toBe("followUp"); + expect(followUp).not.toHaveBeenCalled(); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("queues agent messages behind existing pending work on an idle target", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + const prompt = vi.fn(async (_message: string, _options?: { streamingBehavior?: "steer" | "followUp" }) => {}); + const followUp = vi.fn(async () => true); + const queueAgentMessagePrompt = vi.fn(async (_message: string, _streamingBehavior: "steer" | "followUp") => true); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 1, + prompt, + followUp, + queueAgentMessagePrompt, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "queued behind existing work", + fromState, + origin: "agent", + }), + ).resolves.toMatchObject({ target: { activeSessionId: targetState.activeSessionId } }); + + expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); + expect(queueAgentMessagePrompt.mock.calls[0]?.[1]).toBe("followUp"); + expect(followUp).not.toHaveBeenCalled(); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("queues agent messages while the target is compacting", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + const prompt = vi.fn(async (_message: string, _options?: { streamingBehavior?: "steer" | "followUp" }) => {}); + const queueAgentMessagePrompt = vi.fn(async (_message: string, _streamingBehavior: "steer" | "followUp") => true); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + isCompacting: true, + pendingMessageCount: 0, + prompt, + queueAgentMessagePrompt, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "queued behind compaction", + fromState, + origin: "agent", + }), + ).resolves.toMatchObject({ target: { activeSessionId: targetState.activeSessionId } }); + + expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); + expect(queueAgentMessagePrompt.mock.calls[0]?.[1]).toBe("followUp"); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("queues agent messages while target bash is running", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + const acceptAgentMessagePrompt = vi.fn(async () => {}); + const queueAgentMessagePrompt = vi.fn(async (_message: string, _streamingBehavior: "steer" | "followUp") => true); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + isBashRunning: true, + pendingMessageCount: 0, + acceptAgentMessagePrompt, + queueAgentMessagePrompt, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "queued behind bash", + fromState, + origin: "agent", + }), + ).resolves.toMatchObject({ target: { activeSessionId: targetState.activeSessionId } }); + + expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); + expect(queueAgentMessagePrompt.mock.calls[0]?.[1]).toBe("followUp"); + expect(acceptAgentMessagePrompt).not.toHaveBeenCalled(); + }); + + it("acknowledges queued agent messages after queue insertion", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + let resolveQueuedDelivery: () => void = () => {}; + const waitForAgentMessagePromptDelivery = vi.fn( + () => + new Promise((resolve) => { + resolveQueuedDelivery = resolve; + }), + ); + const queueAgentMessagePrompt = vi.fn(async () => true); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: true, + pendingMessageCount: 0, + queueAgentMessagePrompt, + waitForAgentMessagePromptDelivery, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "queued", + fromState, + origin: "agent", + }), + ).resolves.toMatchObject({ target: { activeSessionId: targetState.activeSessionId } }); + + expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); + expect(waitForAgentMessagePromptDelivery).not.toHaveBeenCalled(); + resolveQueuedDelivery(); + }); + + it("recomputes agent message streaming behavior after waiting for the target lock", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + const promptResolves: Array<() => void> = []; + const prompt = vi.fn( + (_message: string, _options?: { streamingBehavior?: "steer" | "followUp" }) => + new Promise((resolve) => { + promptResolves.push(resolve); + }), + ); + const followUp = vi.fn(async () => true); + const queueAgentMessagePrompt = vi.fn(async (_message: string, _streamingBehavior: "steer" | "followUp") => true); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + prompt, + followUp, + queueAgentMessagePrompt, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + const first = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "first", + fromState, + origin: "agent", + }); + const second = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "second", + fromState, + origin: "agent", + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(prompt).toHaveBeenCalledTimes(1); + (targetState.runtime.session as { isStreaming: boolean }).isStreaming = true; + promptResolves[0]?.(); + await expect(first).resolves.toMatchObject({ message: "first" }); + await Promise.resolve(); + await Promise.resolve(); + + expect(prompt).toHaveBeenCalledTimes(1); + expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); + expect(queueAgentMessagePrompt.mock.calls[0]?.[1]).toBe("followUp"); + expect(followUp).not.toHaveBeenCalled(); + await expect(second).resolves.toMatchObject({ message: "second" }); + }); + + it("rejects agent messages when queued delivery is coalesced", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + const queueAgentMessagePrompt = vi.fn(async () => false); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: true, + pendingMessageCount: 1, + queueAgentMessagePrompt, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "coalesced", + fromState, + origin: "agent", + }), + ).rejects.toThrow("Agent message was not queued"); + expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); + }); + + it("rejects agent messages when direct delivery preflight fails", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + const acceptAgentMessagePrompt = vi.fn( + (_message: string, options?: { preflightResult?: (didSucceed: boolean) => void }) => { + options?.preflightResult?.(false); + return Promise.resolve(); + }, + ); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + acceptAgentMessagePrompt, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "not accepted", + fromState, + origin: "agent", + }), + ).rejects.toThrow("Agent message was not accepted"); + }); + + it("queues agent messages while daemon prompts prepare to stream", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const targetState = makeState("target"); + let resolvePrompt: () => void = () => {}; + let reportPreflight: ((didSucceed: boolean) => void) | undefined; + const prompt = vi.fn((_message: string, options?: { preflightResult?: (didSucceed: boolean) => void }) => { + reportPreflight = options?.preflightResult; + return new Promise((resolve) => { + resolvePrompt = resolve; + }); + }); + const acceptAgentMessagePrompt = vi.fn(async () => {}); + const queueAgentMessagePrompt = vi.fn(async (_message: string, _streamingBehavior: "steer" | "followUp") => true); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + prompt, + acceptAgentMessagePrompt, + queueAgentMessagePrompt, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise | undefined; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(targetState.activeSessionId, targetState); + const promptClient = makeClient("client-1", targetState.activeSessionId); + (promptClient.socket as unknown as { write: ReturnType }).write = vi.fn(); + + internals.handleCommand(promptClient, { + id: "command-1", + type: "prompt", + activeSessionId: targetState.activeSessionId, + message: "normal prompt", + }); + await Promise.resolve(); + await Promise.resolve(); + reportPreflight?.(true); + + const send = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "agent message", + origin: "agent", + }); + await Promise.resolve(); + await send; + expect(acceptAgentMessagePrompt).not.toHaveBeenCalled(); + expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); + expect(queueAgentMessagePrompt.mock.calls[0]?.[1]).toBe("followUp"); + resolvePrompt(); + }); + + it("waits for an in-flight agent-message accept before starting daemon prompts", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const targetState = makeState("target"); + let resolveAccept: () => void = () => {}; + const acceptAgentMessagePrompt = vi.fn( + (_message: string, options?: { preflightResult?: (didSucceed: boolean) => void }) => { + options?.preflightResult?.(true); + return new Promise((resolve) => { + resolveAccept = resolve; + }); + }, + ); + const prompt = vi.fn(async (_message: string, options?: { preflightResult?: (didSucceed: boolean) => void }) => { + options?.preflightResult?.(true); + }); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + prompt, + acceptAgentMessagePrompt, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise | undefined; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(targetState.activeSessionId, targetState); + + const send = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "agent message", + origin: "agent", + }); + await Promise.resolve(); + await Promise.resolve(); + expect(acceptAgentMessagePrompt).toHaveBeenCalledOnce(); + + const promptClient = makeClient("client-1", targetState.activeSessionId); + (promptClient.socket as unknown as { write: ReturnType }).write = vi.fn(); + internals.handleCommand(promptClient, { + id: "command-1", + type: "prompt", + activeSessionId: targetState.activeSessionId, + message: "normal prompt", + }); + await Promise.resolve(); + await Promise.resolve(); + expect(prompt).not.toHaveBeenCalled(); + + resolveAccept(); + await send; + for (let attempt = 0; attempt < 10 && prompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } + + expect(prompt).toHaveBeenCalledOnce(); + }); + + it("queues agent messages while a cron prompt prepares to stream", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const targetState = makeState("target"); + let resolvePrompt: () => void = () => {}; + const prompt = vi.fn( + (_message: string, _options?: unknown) => + new Promise((resolve) => { + resolvePrompt = resolve; + }), + ); + const acceptAgentMessagePrompt = vi.fn(async () => {}); + const queueAgentMessagePrompt = vi.fn(async (_message: string, _streamingBehavior: "steer" | "followUp") => true); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + isBashRunning: false, + pendingMessageCount: 0, + prompt, + acceptAgentMessagePrompt, + queueAgentMessagePrompt, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + runCronJob(job: AgentCronJob): Promise<"skipped" | undefined>; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(targetState.activeSessionId, targetState); + + const cronRun = internals.runCronJob( + makeCronJob({ id: "cron-1", source: "cron", activeSessionId: targetState.activeSessionId }), + ); + await Promise.resolve(); + await Promise.resolve(); + expect(prompt).toHaveBeenCalledOnce(); + + await internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "agent message", + origin: "agent", + }); + + expect(acceptAgentMessagePrompt).not.toHaveBeenCalled(); + expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); + expect(queueAgentMessagePrompt.mock.calls[0]?.[1]).toBe("followUp"); + resolvePrompt(); + await cronRun; + }); + + it("keeps the preparing state until every concurrent prompt settles", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const targetState = makeState("target"); + const promptResolves: Array<() => void> = []; + const prompt = vi.fn( + (_message: string, _options?: unknown) => + new Promise((resolve) => { + promptResolves.push(resolve); + }), + ); + const acceptAgentMessagePrompt = vi.fn(async () => {}); + const queueAgentMessagePrompt = vi.fn(async (_message: string, _streamingBehavior: "steer" | "followUp") => true); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + prompt, + acceptAgentMessagePrompt, + queueAgentMessagePrompt, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise | undefined; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(targetState.activeSessionId, targetState); + const promptClient = makeClient("client-1", targetState.activeSessionId); + (promptClient.socket as unknown as { write: ReturnType }).write = vi.fn(); + + internals.handleCommand(promptClient, { + id: "command-1", + type: "prompt", + activeSessionId: targetState.activeSessionId, + message: "first prompt", + }); + internals.handleCommand(promptClient, { + id: "command-2", + type: "prompt", + activeSessionId: targetState.activeSessionId, + message: "second prompt", + }); + await Promise.resolve(); + await Promise.resolve(); + expect(prompt).toHaveBeenCalledTimes(2); + + // The first prompt settles; the second is still in preflight, so agent + // messages must keep queueing (a plain Set would have lost the flag here). + promptResolves[0]?.(); + await Promise.resolve(); + await Promise.resolve(); + + await internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "agent message", + origin: "agent", + }); + + expect(acceptAgentMessagePrompt).not.toHaveBeenCalled(); + expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); + promptResolves[1]?.(); + }); + + it("re-checks agent message queue capacity after waiting for the target lock", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const fromState = makeState("source"); + const targetState = makeState("target"); + fromState.runtime = { + ...fromState.runtime, + session: { sessionId: "session-source", sessionName: "Source" }, + } as never; + let resolveFirstPrompt: () => void = () => {}; + const acceptAgentMessagePrompt = vi.fn( + () => + new Promise((resolve) => { + resolveFirstPrompt = resolve; + }), + ); + const followUp = vi.fn(async () => true); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + acceptAgentMessagePrompt, + followUp, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + + const first = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "first", + fromState, + origin: "agent", + }); + const second = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "second", + fromState, + origin: "agent", + }); + await Promise.resolve(); + await Promise.resolve(); + (targetState.runtime.session as { pendingMessageCount: number }).pendingMessageCount = 20; + + resolveFirstPrompt(); + await expect(first).resolves.toMatchObject({ message: "first" }); + await expect(second).rejects.toThrow("Target session has too many pending messages"); + expect(followUp).not.toHaveBeenCalled(); + }); + + it("rate limits CLI agent messages by stable daemon identity", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const targetState = makeState("target"); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + prompt: vi.fn(async () => {}), + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + }; + internals.sessions.set(targetState.activeSessionId, targetState); + + for (let i = 0; i < 3; i++) { + await expect( + internals.handleCommand(makeClient(`client-${i}`, targetState.activeSessionId), { + id: `command-${i}`, + type: "send_message", + targetActiveSessionId: targetState.activeSessionId, + message: `message ${i}`, + }), + ).resolves.toMatchObject({ success: true }); + } + await expect( + internals.handleCommand(makeClient("client-4", targetState.activeSessionId), { + id: "command-4", + type: "send_message", + targetActiveSessionId: targetState.activeSessionId, + message: "over limit", + }), + ).rejects.toThrow("Agent messaging rate limit exceeded"); + }); + + it("holds the target lock while clearing queued agent messages", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const targetState = makeState("target"); + let resolvePrompt: () => void = () => {}; + const acceptAgentMessagePrompt = vi.fn( + (_message: string, options?: { preflightResult?: (didSucceed: boolean) => void }) => { + options?.preflightResult?.(true); + return new Promise((resolve) => { + resolvePrompt = resolve; + }); + }, + ); + const clearQueuedUserMessagesMatching = vi.fn(() => ({ steering: [], followUp: [] })); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + acceptAgentMessagePrompt, + clearQueuedUserMessagesMatching, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(targetState.activeSessionId, targetState); + + const send = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "first", + origin: "agent", + }); + await Promise.resolve(); + await Promise.resolve(); + + const clear = internals.handleCommand(makeClient("client-1", targetState.activeSessionId), { + id: "command-1", + type: "agent_messages_clear", + activeSessionId: targetState.activeSessionId, + }); + await Promise.resolve(); + expect(clearQueuedUserMessagesMatching).not.toHaveBeenCalled(); + + resolvePrompt(); + await send; + await clear; + expect(clearQueuedUserMessagesMatching).toHaveBeenCalledOnce(); + }); + + it("rejects agent messages when pause wins the target lock", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const targetState = makeState("target"); + let resolveBlockedClear: () => void = () => {}; + const clearQueuedUserMessagesMatching = vi.fn( + () => + new Promise<{ steering: string[]; followUp: string[] }>((resolve) => { + resolveBlockedClear = () => resolve({ steering: [], followUp: [] }); + }), + ); + const acceptAgentMessagePrompt = vi.fn(async () => {}); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + acceptAgentMessagePrompt, + clearQueuedUserMessagesMatching, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(targetState.activeSessionId, targetState); + + const pause = internals.handleCommand(makeClient("client-1", targetState.activeSessionId), { + id: "command-1", + type: "agent_messages_pause", + }); + await Promise.resolve(); + await Promise.resolve(); + + const send = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "after pause requested", + origin: "agent", + }); + await Promise.resolve(); + expect(acceptAgentMessagePrompt).not.toHaveBeenCalled(); + + resolveBlockedClear(); + await pause; + await expect(send).rejects.toThrow("Agent messaging is paused"); + expect(acceptAgentMessagePrompt).not.toHaveBeenCalled(); + }); + + it("rejects agent messages when the target session changes before delivery", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const targetState = makeState("target"); + let resolveBlockedClear: () => void = () => {}; + const clearQueuedUserMessagesMatching = vi.fn( + () => + new Promise<{ steering: string[]; followUp: string[] }>((resolve) => { + resolveBlockedClear = () => resolve({ steering: [], followUp: [] }); + }), + ); + const acceptAgentMessagePrompt = vi.fn(async () => {}); + targetState.runtime = { + ...targetState.runtime, + cwd: "/tmp", + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + acceptAgentMessagePrompt, + clearQueuedUserMessagesMatching, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(targetState.activeSessionId, targetState); + + const clear = internals.handleCommand(makeClient("client-1", targetState.activeSessionId), { + id: "command-1", + type: "agent_messages_clear", + activeSessionId: targetState.activeSessionId, + }); + await Promise.resolve(); + await Promise.resolve(); + + const send = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "after session switch", + origin: "agent", + }); + await Promise.resolve(); + (targetState.runtime.session as { sessionId: string }).sessionId = "session-replacement"; + resolveBlockedClear(); + await clear; + + await expect(send).rejects.toThrow("Target session changed before agent message delivery"); + expect(acceptAgentMessagePrompt).not.toHaveBeenCalled(); + }); + + it("rejects agent messages when the target session closes before delivery", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const targetState = makeState("target"); + targetState.extensionUiRequests = new Map(); + let resolveBlockedClear: () => void = () => {}; + const clearQueuedUserMessagesMatching = vi.fn( + () => + new Promise<{ steering: string[]; followUp: string[] }>((resolve) => { + resolveBlockedClear = () => resolve({ steering: [], followUp: [] }); + }), + ); + const acceptAgentMessagePrompt = vi.fn(async () => {}); + const dispose = vi.fn(async () => {}); + targetState.runtime = { + ...targetState.runtime, + dispose, + cwd: "/tmp", + metadata: { kind: "subagent", createdAt: 1 }, + session: { + sessionId: "session-target", + sessionName: "Target", + isStreaming: false, + pendingMessageCount: 0, + messages: [], + acceptAgentMessagePrompt, + clearQueuedUserMessagesMatching, + abort: vi.fn(async () => {}), + dispose: vi.fn(), + sessionManager: { appendSessionState: vi.fn(), hasUserContent: () => true }, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(targetState.activeSessionId, targetState); + + const clear = internals.handleCommand(makeClient("client-1", targetState.activeSessionId), { + id: "command-1", + type: "agent_messages_clear", + activeSessionId: targetState.activeSessionId, + }); + await Promise.resolve(); + await Promise.resolve(); + + const send = internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "after close requested", + origin: "agent", + }); + await Promise.resolve(); + + const close = internals.handleCommand(makeClient("client-2", targetState.activeSessionId), { + id: "command-2", + type: "kill", + activeSessionId: targetState.activeSessionId, + }); + await close; + expect(dispose).toHaveBeenCalledOnce(); + + resolveBlockedClear(); + await clear; + await expect(send).rejects.toThrow("Target session is closing before agent message delivery"); + expect(acceptAgentMessagePrompt).not.toHaveBeenCalled(); + }); + + it("rejects agent messages to the sending session", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const state = makeState("self"); + state.runtime = { + ...state.runtime, + cwd: "/tmp", + session: { + sessionId: "session-self", + sessionName: "Self", + isStreaming: false, + pendingMessageCount: 0, + prompt: vi.fn(async () => {}), + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(state.activeSessionId, state); + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: state.activeSessionId, + message: "self", + fromState: state, + origin: "agent", + }), + ).rejects.toThrow("Agent messaging cannot target the sending session"); + expect(state.runtime.session.prompt).not.toHaveBeenCalled(); + }); + it("sends dialog extension UI requests only to UI-capable clients", () => { const lineClient = makeClient("line-client", "active", false); const uiClient = makeClient("ui-client", "active", true); @@ -143,6 +2163,134 @@ describe("daemon mode helpers", () => { } }); + it("makes daemon host controllers available during session_start extension binding", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-controller-race-")); + try { + let listedAgentsDuringBind = 0; + const createRuntime = vi.fn(async (options: Parameters[0]) => { + const session = makeRuntimeSession(options.sessionManager); + session.bindExtensions = vi.fn(async () => { + const result = options.sessionOptions?.agentMessageController?.listAgents(); + expect(result?.current?.activeSessionId).toBeTruthy(); + listedAgentsDuringBind++; + }); + return { + session, + extensionsResult: { extensions: [], errors: [], runtime: {} } as unknown as Awaited< + ReturnType + >["extensionsResult"], + services: { cwd: options.cwd, agentDir: options.agentDir } as Awaited< + ReturnType + >["services"], + diagnostics: [], + }; + }); + const daemon = new AgentDaemon(join(tempDir, "daemon.sock"), { + defaultSessionConfig: { agentDir: tempDir, cwd: tempDir, sessionDir: tempDir }, + createRuntime, + }); + const create = ( + daemon as unknown as { + createRuntime(command: Extract): Promise; + } + ).createRuntime.bind(daemon); + + await create({ type: "create", sessionPath: join(tempDir, "session.jsonl") }); + + expect(listedAgentsDuringBind).toBe(1); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("excludes half-bound sessions from targeting until extension binding completes", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-binding-gate-")); + try { + let releaseBind: () => void = () => {}; + const bindBarrier = new Promise((resolve) => { + releaseBind = resolve; + }); + const createRuntime = vi.fn(async (options: Parameters[0]) => { + const session = makeRuntimeSession(options.sessionManager); + session.bindExtensions = vi.fn(async () => { + await bindBarrier; + }); + return { + session, + extensionsResult: { extensions: [], errors: [], runtime: {} } as unknown as Awaited< + ReturnType + >["extensionsResult"], + services: { cwd: options.cwd, agentDir: options.agentDir } as Awaited< + ReturnType + >["services"], + diagnostics: [], + }; + }); + const daemon = new AgentDaemon(join(tempDir, "daemon.sock"), { + defaultSessionConfig: { agentDir: tempDir, cwd: tempDir, sessionDir: tempDir }, + createRuntime, + }); + const internals = daemon as unknown as { + sessions: Map; + createRuntime(command: Extract): Promise; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise | undefined; + createAgentMessageListResult(current: ActiveSessionState): { + agents: Array<{ activeSessionId: string }>; + }; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + const fromState = makeState("source"); + fromState.runtime = { + ...fromState.runtime, + cwd: tempDir, + session: { sessionId: "session-source", sessionName: "Source", isStreaming: false, pendingMessageCount: 0 }, + } as never; + internals.sessions.set(fromState.activeSessionId, fromState); + + const created = internals.createRuntime({ type: "create", sessionPath: join(tempDir, "session.jsonl") }); + for (let attempt = 0; attempt < 50 && internals.sessions.size < 2; attempt++) { + await Promise.resolve(); + } + const bindingId = [...internals.sessions.keys()].find((id) => id !== fromState.activeSessionId); + expect(bindingId).toBeTruthy(); + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: bindingId as string, + message: "too early", + fromState, + origin: "agent", + }), + ).rejects.toThrow("still initializing"); + await expect( + Promise.resolve( + internals.handleCommand(makeClient("client-1", bindingId as string), { + id: "command-1", + type: "attach", + activeSessionId: bindingId as string, + }), + ), + ).rejects.toThrow("still initializing"); + expect(internals.createAgentMessageListResult(fromState).agents.map((agent) => agent.activeSessionId)).toEqual( + [fromState.activeSessionId], + ); + + releaseBind(); + await created; + + expect(internals.createAgentMessageListResult(fromState).agents.map((agent) => agent.activeSessionId)).toEqual( + expect.arrayContaining([fromState.activeSessionId, bindingId]), + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("includes paused jobs in the default cron list", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-cron-list-")); try { @@ -366,6 +2514,168 @@ describe("daemon mode helpers", () => { expect(removeQueuedFollowUp).not.toHaveBeenCalled(); }); + it("defers heartbeat cron jobs while the target is accepting an agent message", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const prompt = vi.fn(async () => {}); + const followUp = vi.fn(async () => true); + const state = makeState("active-1") as ActiveSessionState & { + runtime: ActiveSessionState["runtime"] & { + session: { + isStreaming: boolean; + isBashRunning: boolean; + pendingMessageCount: number; + prompt: typeof prompt; + followUp: typeof followUp; + }; + }; + }; + state.runtime.session = { + isStreaming: false, + isBashRunning: false, + pendingMessageCount: 0, + prompt, + followUp, + } as never; + const internals = daemon as unknown as { + sessions: Map; + agentMessageAcceptingTargets: Set; + runCronJob(job: AgentCronJob): Promise<"skipped" | undefined>; + }; + internals.sessions.set(state.activeSessionId, state); + internals.agentMessageAcceptingTargets.add(state.activeSessionId); + + const result = await internals.runCronJob( + makeCronJob({ id: "heartbeat-1", source: "heartbeat", activeSessionId: state.activeSessionId }), + ); + + expect(result).toBe("skipped"); + expect(prompt).not.toHaveBeenCalled(); + expect(followUp).not.toHaveBeenCalled(); + }); + + it("queues generic cron jobs while the target is accepting an agent message", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const prompt = vi.fn(async () => {}); + const followUp = vi.fn(async () => true); + const state = makeState("active-1") as ActiveSessionState & { + runtime: ActiveSessionState["runtime"] & { + session: { + isStreaming: boolean; + isBashRunning: boolean; + pendingMessageCount: number; + prompt: typeof prompt; + followUp: typeof followUp; + }; + }; + }; + state.runtime.session = { + isStreaming: false, + isBashRunning: false, + pendingMessageCount: 0, + prompt, + followUp, + } as never; + const internals = daemon as unknown as { + sessions: Map; + agentMessageAcceptingTargets: Set; + runCronJob(job: AgentCronJob): Promise<"skipped" | undefined>; + }; + internals.sessions.set(state.activeSessionId, state); + internals.agentMessageAcceptingTargets.add(state.activeSessionId); + + await internals.runCronJob(makeCronJob({ id: "cron-1", source: "cron", activeSessionId: state.activeSessionId })); + + expect(followUp).toHaveBeenCalledWith("heartbeat prompt"); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("defers heartbeat cron jobs while an accepted agent message prompt is in flight", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const prompt = vi.fn(async () => {}); + const followUp = vi.fn(async () => true); + const state = makeState("active-1") as ActiveSessionState & { + runtime: ActiveSessionState["runtime"] & { + session: { + isStreaming: boolean; + isBashRunning: boolean; + hasAcceptedPromptInFlight: boolean; + pendingMessageCount: number; + prompt: typeof prompt; + followUp: typeof followUp; + }; + }; + }; + state.runtime.session = { + isStreaming: false, + isBashRunning: false, + hasAcceptedPromptInFlight: true, + pendingMessageCount: 0, + prompt, + followUp, + } as never; + (daemon as unknown as { sessions: Map }).sessions.set(state.activeSessionId, state); + + const result = await ( + daemon as unknown as { runCronJob(job: AgentCronJob): Promise<"skipped" | undefined> } + ).runCronJob(makeCronJob({ id: "heartbeat-1", source: "heartbeat", activeSessionId: state.activeSessionId })); + + expect(result).toBe("skipped"); + expect(prompt).not.toHaveBeenCalled(); + expect(followUp).not.toHaveBeenCalled(); + }); + + it("queues generic cron jobs behind accepted agent message prompts", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const prompt = vi.fn(async () => {}); + const followUp = vi.fn(async () => true); + const state = makeState("active-1") as ActiveSessionState & { + runtime: ActiveSessionState["runtime"] & { + session: { + isStreaming: boolean; + hasAcceptedPromptInFlight: boolean; + pendingMessageCount: number; + prompt: typeof prompt; + followUp: typeof followUp; + }; + }; + }; + state.runtime.session = { + isStreaming: false, + hasAcceptedPromptInFlight: true, + pendingMessageCount: 0, + prompt, + followUp, + } as never; + (daemon as unknown as { sessions: Map }).sessions.set(state.activeSessionId, state); + + await (daemon as unknown as { runCronJob(job: AgentCronJob): Promise<"skipped" | undefined> }).runCronJob( + makeCronJob({ id: "cron-1", source: "cron", activeSessionId: state.activeSessionId }), + ); + + expect(followUp).toHaveBeenCalledWith("heartbeat prompt"); + expect(prompt).not.toHaveBeenCalled(); + }); + it("queues generic cron jobs behind pending messages", async () => { const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, @@ -429,11 +2739,15 @@ describe("daemon mode helpers", () => { makeCronJob({ id: "heartbeat-1", source: "heartbeat", activeSessionId: state.activeSessionId }), ); - expect(prompt).toHaveBeenCalledWith("heartbeat prompt", { - streamingBehavior: "followUp", - followUpQueueKey: "heartbeat:heartbeat-1", - source: "rpc", - }); + // The preparing guard adds an internal preflightResult hook. + expect(prompt).toHaveBeenCalledWith( + "heartbeat prompt", + expect.objectContaining({ + streamingBehavior: "followUp", + followUpQueueKey: "heartbeat:heartbeat-1", + source: "rpc", + }), + ); expect(followUp).not.toHaveBeenCalled(); }); @@ -470,11 +2784,15 @@ describe("daemon mode helpers", () => { makeCronJob({ id: "cron-1", source: "cron", activeSessionId: state.activeSessionId }), ); - expect(prompt).toHaveBeenCalledWith("heartbeat prompt", { - streamingBehavior: "followUp", - followUpQueueKey: undefined, - source: "rpc", - }); + // The preparing guard adds an internal preflightResult hook. + expect(prompt).toHaveBeenCalledWith( + "heartbeat prompt", + expect.objectContaining({ + streamingBehavior: "followUp", + followUpQueueKey: undefined, + source: "rpc", + }), + ); expect(followUp).not.toHaveBeenCalled(); }); diff --git a/packages/coding-agent/test/daemon-session-list.test.ts b/packages/coding-agent/test/daemon-session-list.test.ts index 4440bb9f0d..2ba851170b 100644 --- a/packages/coding-agent/test/daemon-session-list.test.ts +++ b/packages/coding-agent/test/daemon-session-list.test.ts @@ -73,6 +73,21 @@ describe("buildSessionList", () => { expect(entries[0]?.activity).toBe("working"); }); + it("counts accepted in-flight agent prompts as pending work", () => { + const oneMessage = [{ role: "user", content: "hi" }] as unknown as AgentMessage[]; + const summary = summaryForActiveSession( + makeState({ + activeSessionId: "accepted", + messages: oneMessage, + summaryState: { basedOnMessageCount: 1 } as ActiveSessionState["summaryState"], + hasAcceptedPromptInFlight: true, + }), + ); + + expect(summary.pendingMessageCount).toBe(1); + expect(summary.activity).toBe("working"); + }); + it("marks a finished subagent idle instead of holding it at working", () => { const oneMessage = [{ role: "user", content: "hi" }] as unknown as AgentMessage[]; const entries = buildSessionList( @@ -425,6 +440,7 @@ interface StateOptions { summaryState?: ActiveSessionState["summaryState"]; childRunStatuses?: Record; hasRunningRlmChildren?: boolean; + hasAcceptedPromptInFlight?: boolean; metadata?: { kind: "top-level" | "subagent"; createdAt: number; @@ -468,6 +484,7 @@ function makeState(options: StateOptions): ActiveSessionState { messages: options.messages ?? ([] as AgentMessage[]), getRlmChildRunStatus: (childId: string) => options.childRunStatuses?.[childId], hasRunningRlmChildren: () => options.hasRunningRlmChildren ?? false, + hasAcceptedPromptInFlight: options.hasAcceptedPromptInFlight ?? false, getCurrentRecap: () => undefined, pendingMessageCount: 0, state: { diff --git a/packages/coding-agent/test/kernel-agent-message-skill.test.ts b/packages/coding-agent/test/kernel-agent-message-skill.test.ts new file mode 100644 index 0000000000..3bc28a6f4f --- /dev/null +++ b/packages/coding-agent/test/kernel-agent-message-skill.test.ts @@ -0,0 +1,129 @@ +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getBundledSkillsDir } from "../src/config.js"; +import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; +import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; + +function bundledAgentMessageSkill(): PythonSkillRuntimeInfo { + const packagePath = join(getBundledSkillsDir(), "agent-message"); + return { + name: "agent-message", + importName: "agent_message", + packagePath, + pyprojectPath: join(packagePath, "pyproject.toml"), + }; +} + +describe("agent-message skill over the kernel host bridge", () => { + let tempDir: string; + let provisioner: IpythonKernelProvisioner | undefined; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-agent-message-skill-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await provisioner?.dispose(); + provisioner = undefined; + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("lists agents and sends without exposing a spoofable sender", async () => { + const requests: Array<{ type: string; payload: Record }> = []; + provisioner = new IpythonKernelProvisioner(tempDir, { + pythonSkills: [bundledAgentMessageSkill()], + hostHandlers: { + "agent_message.list": async (payload) => { + requests.push({ type: "agent_message.list", payload }); + return { + current: { activeSessionId: "alpha", sessionId: "session-alpha" }, + agents: [ + { + activeSessionId: "alpha", + sessionId: "session-alpha", + cwd: tempDir, + isStreaming: false, + pendingMessageCount: 0, + }, + { + activeSessionId: "beta", + sessionId: "session-beta", + sessionName: "Beta", + cwd: tempDir, + isStreaming: true, + pendingMessageCount: 1, + }, + ], + }; + }, + "agent_message.send": async (payload) => { + requests.push({ type: "agent_message.send", payload }); + return { + id: "agentmsg-test", + source: "agent_message", + target: { activeSessionId: payload.target, sessionId: "session-beta" }, + from: { activeSessionId: "alpha", sessionId: "session-alpha" }, + message: payload.message, + deliveryStatus: "queued", + queuedAt: "2026-06-16T00:00:00.000Z", + deliveryMode: payload.mode, + }; + }, + }, + }); + + const manager = await provisioner.ensure(); + const result = await manager.execute(` +import json +agents = await agent_message.list_agents() +receipt = await agent_message.send("beta", "hello beta", mode="follow_up") +print(json.dumps({"agents": agents, "receipt": receipt}, sort_keys=True)) +`); + + expect(result.status).toBe("ok"); + const output = JSON.parse(result.stdout.trim()); + expect(output.agents.agents).toHaveLength(2); + expect(output.receipt).toMatchObject({ + id: "agentmsg-test", + source: "agent_message", + message: "hello beta", + deliveryStatus: "queued", + deliveryMode: "follow_up", + }); + expect(requests[0]).toMatchObject({ type: "agent_message.list", payload: { type: "agent_message.list" } }); + expect(requests[1]).toMatchObject({ + type: "agent_message.send", + payload: { + type: "agent_message.send", + target: "beta", + message: "hello beta", + mode: "follow_up", + }, + }); + expect(requests[1].payload).not.toHaveProperty("from"); + }); + + it("validates mode before sending to the host", async () => { + provisioner = new IpythonKernelProvisioner(tempDir, { + pythonSkills: [bundledAgentMessageSkill()], + hostHandlers: { + "agent_message.send": async () => { + throw new Error("should not reach host"); + }, + }, + }); + + const manager = await provisioner.ensure(); + const result = await manager.execute(` +try: + await agent_message.send("beta", "hello", mode="broadcast") +except ValueError as error: + print(f"ValueError: {error}") +`); + expect(result.status).toBe("ok"); + expect(result.stdout.trim()).toBe('ValueError: mode must be "auto", "follow_up", or "steer"'); + }); +}); diff --git a/packages/coding-agent/test/kernel-agent-observe-skill.test.ts b/packages/coding-agent/test/kernel-agent-observe-skill.test.ts new file mode 100644 index 0000000000..0608bd6c0a --- /dev/null +++ b/packages/coding-agent/test/kernel-agent-observe-skill.test.ts @@ -0,0 +1,113 @@ +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getBundledSkillsDir } from "../src/config.js"; +import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; +import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; + +function bundledAgentObserveSkill(): PythonSkillRuntimeInfo { + const packagePath = join(getBundledSkillsDir(), "agent-observe"); + return { + name: "agent-observe", + importName: "agent_observe", + packagePath, + pyprojectPath: join(packagePath, "pyproject.toml"), + }; +} + +describe("agent-observe skill over the kernel host bridge", () => { + let tempDir: string; + let provisioner: IpythonKernelProvisioner | undefined; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-agent-observe-skill-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await provisioner?.dispose(); + provisioner = undefined; + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("lists agents and reads bounded recent messages", async () => { + const requests: Array<{ type: string; payload: Record }> = []; + provisioner = new IpythonKernelProvisioner(tempDir, { + pythonSkills: [bundledAgentObserveSkill()], + hostHandlers: { + "agent_observe.list": async (payload) => { + requests.push({ type: "agent_observe.list", payload }); + return { + current: { activeSessionId: "alpha", sessionId: "session-alpha", isCurrent: true }, + agents: [ + { activeSessionId: "alpha", sessionId: "session-alpha", sessionName: "Orchestrator" }, + { activeSessionId: "beta", sessionId: "session-beta", sessionName: "Worker" }, + ], + }; + }, + "agent_observe.get": async (payload) => { + requests.push({ type: "agent_observe.get", payload }); + return { agent: { activeSessionId: payload.target, sessionId: "session-beta", status: "model" } }; + }, + "agent_observe.recent": async (payload) => { + requests.push({ type: "agent_observe.recent", payload }); + return { + agent: { activeSessionId: payload.target, sessionId: "session-beta" }, + messages: [{ index: 1, role: "assistant", text: "working", truncated: false }], + limit: payload.limit, + maxChars: payload.max_chars, + truncated: false, + }; + }, + }, + }); + + const manager = await provisioner.ensure(); + const result = await manager.execute(` +import json +agents = await agent_observe.list_agents() +agent = await agent_observe.get_agent("beta") +recent = await agent_observe.recent_messages("beta", limit=3, max_chars=120) +print(json.dumps({"agents": agents, "agent": agent, "recent": recent}, sort_keys=True)) +`); + + expect(result.status).toBe("ok"); + const output = JSON.parse(result.stdout.trim()); + expect(output.agents.agents).toHaveLength(2); + expect(output.agent.agent).toMatchObject({ activeSessionId: "beta", status: "model" }); + expect(output.recent.messages).toEqual([{ index: 1, role: "assistant", text: "working", truncated: false }]); + expect(requests.map((request) => request.type)).toEqual([ + "agent_observe.list", + "agent_observe.get", + "agent_observe.recent", + ]); + expect(requests[2].payload).toMatchObject({ + type: "agent_observe.recent", + target: "beta", + limit: 3, + max_chars: 120, + }); + }); + + it("validates argument types before sending to the host", async () => { + provisioner = new IpythonKernelProvisioner(tempDir, { + pythonSkills: [bundledAgentObserveSkill()], + hostHandlers: { + "agent_observe.get": async () => { + throw new Error("should not reach host"); + }, + }, + }); + + const manager = await provisioner.ensure(); + const result = await manager.execute(` +try: + await agent_observe.get_agent(123) +except TypeError as error: + print(f"TypeError: {error}") +`); + expect(result.status).toBe("ok"); + expect(result.stdout.trim()).toBe("TypeError: target must be str, got int"); + }); +}); diff --git a/packages/coding-agent/test/kernel-bootstrap.test.ts b/packages/coding-agent/test/kernel-bootstrap.test.ts index 84e5080464..8c68300737 100644 --- a/packages/coding-agent/test/kernel-bootstrap.test.ts +++ b/packages/coding-agent/test/kernel-bootstrap.test.ts @@ -65,6 +65,19 @@ version = "0.1.0" }; } +function createPythonSkillWithDependency(name: string, dependencyName: string): KernelPythonSkill { + const skill = createPythonSkill(name); + writeFileSync( + skill.pyprojectPath, + `[project] +name = "${name}" +version = "0.1.0" +dependencies = ["${dependencyName}"] +`, + ); + return skill; +} + function writeFakePython(filePath: string, importableModules: readonly string[]): void { const cases = importableModules.map((moduleName) => ` "import ${moduleName}") exit 0 ;;`).join("\n"); const runtimeCase = importableModules.includes("rlm") ? ' *"_harness_methods"*) exit 0 ;;' : ""; @@ -255,6 +268,73 @@ describe("kernel bootstrap", () => { ]); }); + it("installs sibling Python skill dependencies with dependent editable packages", async () => { + const logPath = installFakeUv(); + const venv = join(tempDir, "kernel-venv"); + const dependencySkill = createPythonSkill("agent-observe"); + const dependentSkill = createPythonSkillWithDependency("orchestration-heartbeat", "agent-observe"); + process.env.PRIME_AGENT_KERNEL_VENV = venv; + + await expect(ensureKernelPython({ pythonSkills: [dependentSkill] })).resolves.toBe(join(venv, "bin", "python")); + + const log = readFileSync(logPath, "utf8"); + expect(log).toContain(`--editable ${dependencySkill.packagePath}`); + expect(log).toContain(`--editable ${dependentSkill.packagePath}`); + const version = JSON.parse(readFileSync(join(venv, ".bootstrap-version"), "utf8")); + expect(version.pythonSkills).toEqual([ + { + importName: dependencySkill.importName, + packagePath: dependencySkill.packagePath, + pyprojectPath: dependencySkill.pyprojectPath, + pyprojectHash: pyprojectHash(dependencySkill.pyprojectPath), + }, + { + importName: dependentSkill.importName, + packagePath: dependentSkill.packagePath, + pyprojectPath: dependentSkill.pyprojectPath, + pyprojectHash: pyprojectHash(dependentSkill.pyprojectPath), + }, + ]); + }); + + it("installs sibling Python skill dependencies when package and directory names differ", async () => { + const logPath = installFakeUv(); + const venv = join(tempDir, "kernel-venv"); + const dependencySkill = createPythonSkill("attach-image"); + writeFileSync( + dependencySkill.pyprojectPath, + `[project] +name = "prime-agent-skill-attach-image" +version = "0.1.0" +`, + ); + const dependentSkill = createPythonSkillWithDependency( + "orchestration-heartbeat", + "prime-agent-skill-attach-image", + ); + process.env.PRIME_AGENT_KERNEL_VENV = venv; + + await expect(ensureKernelPython({ pythonSkills: [dependentSkill] })).resolves.toBe(join(venv, "bin", "python")); + + const log = readFileSync(logPath, "utf8"); + expect(log).toContain(`--editable ${dependencySkill.packagePath}`); + expect(log).toContain(`--editable ${dependentSkill.packagePath}`); + }); + + it("parses Python skill dependencies with extras", async () => { + const logPath = installFakeUv(); + const venv = join(tempDir, "kernel-venv"); + const dependencySkill = createPythonSkill("gidgethub"); + const dependentSkill = createPythonSkillWithDependency("orchestration-heartbeat", "gidgethub[httpx]>4.0.0"); + process.env.PRIME_AGENT_KERNEL_VENV = venv; + + await expect(ensureKernelPython({ pythonSkills: [dependentSkill] })).resolves.toBe(join(venv, "bin", "python")); + + const log = readFileSync(logPath, "utf8"); + expect(log).toContain(`--editable ${dependencySkill.packagePath}`); + expect(log).toContain(`--editable ${dependentSkill.packagePath}`); + }); + it("syncs a warm venv when a Python skill pyproject changes", async () => { const logPath = installFakeUv(); const venv = join(tempDir, "kernel-venv"); diff --git a/packages/coding-agent/test/kernel-orchestration-heartbeat-skill.test.ts b/packages/coding-agent/test/kernel-orchestration-heartbeat-skill.test.ts new file mode 100644 index 0000000000..308538117d --- /dev/null +++ b/packages/coding-agent/test/kernel-orchestration-heartbeat-skill.test.ts @@ -0,0 +1,260 @@ +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getBundledSkillsDir } from "../src/config.js"; +import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; +import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; + +function bundledPythonSkill(name: string, importName: string): PythonSkillRuntimeInfo { + const packagePath = join(getBundledSkillsDir(), name); + return { + name, + importName, + packagePath, + pyprojectPath: join(packagePath, "pyproject.toml"), + }; +} + +describe("orchestration heartbeat skill over bundled host bridges", () => { + let tempDir: string; + let provisioner: IpythonKernelProvisioner | undefined; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-orchestration-heartbeat-skill-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await provisioner?.dispose(); + provisioner = undefined; + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("creates then refreshes a labeled RLM heartbeat from active session observations", async () => { + const requests: Array<{ type: string; payload: Record }> = []; + let hasHeartbeat = false; + let missingOnce = false; + let scheduleExpression = "every 5m"; + + provisioner = new IpythonKernelProvisioner(tempDir, { + pythonSkills: [bundledPythonSkill("orchestration-heartbeat", "orchestration_heartbeat")], + hostHandlers: { + "agent_observe.list": async (payload) => { + requests.push({ type: "agent_observe.list", payload }); + return { + current: { sessionName: "orch", sessionId: "sess-orch", activeSessionId: "active-orch" }, + agents: [ + { + sessionName: "orch", + sessionId: "sess-orch", + activeSessionId: "active-orch", + cwd: "/repo/orch", + status: "idle", + isStreaming: false, + pendingMessageCount: 0, + }, + { + sessionName: "autoenv", + sessionId: "sess-autoenv", + activeSessionId: "active-autoenv", + cwd: "/repo/autoenv", + status: "tool", + isStreaming: false, + pendingMessageCount: 1, + }, + { + sessionName: "emulatorBench", + sessionId: "sess-emulator", + activeSessionId: "active-emulator", + cwd: "/repo/emulator", + status: "idle", + isStreaming: false, + pendingMessageCount: 0, + }, + ], + }; + }, + "rlm_heartbeat.list": async (payload) => { + requests.push({ type: "rlm_heartbeat.list", payload }); + return { + heartbeats: hasHeartbeat + ? [ + { + id: "job-orch", + status: "active", + label: "orchestrator", + instruction: "old instruction", + schedule: { kind: "interval", expression: scheduleExpression }, + }, + { + id: "job-missing", + status: "active", + label: "missing-on-update", + instruction: "old instruction", + schedule: { kind: "interval", expression: scheduleExpression }, + }, + { + id: "job-paused", + status: "paused", + label: "paused-orchestrator", + instruction: "old instruction", + schedule: { kind: "interval", expression: scheduleExpression }, + }, + { + id: "job-plain", + status: "active", + label: "plain-schedule", + instruction: "old instruction", + schedule: { kind: "interval", expression: "10m" }, + }, + ] + : [], + }; + }, + "rlm_heartbeat.create": async (payload) => { + requests.push({ type: "rlm_heartbeat.create", payload }); + hasHeartbeat = true; + scheduleExpression = String(payload.interval ?? "5m").startsWith("every ") + ? String(payload.interval) + : `every ${payload.interval ?? "5m"}`; + return { + heartbeat: { + id: "job-orch", + status: "active", + label: payload.label, + instruction: payload.instruction, + schedule: { kind: "interval", expression: scheduleExpression }, + }, + }; + }, + "rlm_heartbeat.update": async (payload) => { + requests.push({ type: "rlm_heartbeat.update", payload }); + if (payload.label === "missing-on-update" && !missingOnce) { + missingOnce = true; + return { heartbeat: null }; + } + if (payload.interval) { + scheduleExpression = String(payload.interval).startsWith("every ") + ? String(payload.interval) + : `every ${payload.interval}`; + } + return { + heartbeat: { + id: payload.id, + status: "active", + label: payload.label, + instruction: payload.instruction, + schedule: { kind: "interval", expression: scheduleExpression }, + }, + }; + }, + }, + }); + + const manager = await provisioner.ensure(); + const result = await manager.execute(` +import json +created = await orchestration_heartbeat.initialize(focus="keep long-running sessions moving") +updated = await orchestration_heartbeat.initialize(interval="10m") +try: + await orchestration_heartbeat.initialize(interval="10m", label="missing-on-update") +except RuntimeError as error: + missing_once_error = str(error) +paused = await orchestration_heartbeat.initialize(interval="10m", label="paused-orchestrator") +refreshed = await orchestration_heartbeat.initialize(interval="10m") +plain = await orchestration_heartbeat.initialize(interval="every 10m", label="plain-schedule") +print(json.dumps({ + "created_action": created["action"], + "updated_action": updated["action"], + "missing_once_error": missing_once_error, + "paused_action": paused["action"], + "refreshed_action": refreshed["action"], + "plain_action": plain["action"], + "created_label": created["heartbeat"]["label"], + "updated_label": updated["heartbeat"]["label"], + "session_names": [agent.get("sessionName") for agent in updated["sessions"]], + "instruction": updated["instruction"], +}, sort_keys=True)) +`); + + expect(result.status).toBe("ok"); + const output = JSON.parse(result.stdout.trim()); + expect(output).toMatchObject({ + created_action: "created", + updated_action: "updated", + paused_action: "updated", + refreshed_action: "updated", + plain_action: "updated", + created_label: "orchestrator", + updated_label: "orchestrator", + session_names: ["autoenv", "emulatorBench"], + }); + expect(output.missing_once_error).toContain("RLM heartbeat job-missing disappeared"); + expect(output.instruction).toContain("Use agent_observe to inspect active Prime Agent sessions"); + expect(output.instruction).toContain("recommend the exact action and draft the target message"); + expect(output.instruction).toContain("Do not send cross-session messages until the user approves"); + expect(output.instruction).not.toContain("name=orch"); + expect(output.instruction).toContain("name=autoenv"); + expect(output.instruction).toContain("session_id=sess-autoenv"); + expect(output.instruction).toContain("active_session_id=active-autoenv"); + expect(output.instruction).toContain("streaming=False"); + expect(output.instruction).toContain("pending_messages=1"); + expect(requests.map((request) => request.type)).toEqual([ + "agent_observe.list", + "rlm_heartbeat.list", + "rlm_heartbeat.create", + "agent_observe.list", + "rlm_heartbeat.list", + "rlm_heartbeat.update", + "agent_observe.list", + "rlm_heartbeat.list", + "rlm_heartbeat.update", + "agent_observe.list", + "rlm_heartbeat.list", + "rlm_heartbeat.update", + "agent_observe.list", + "rlm_heartbeat.list", + "rlm_heartbeat.update", + "agent_observe.list", + "rlm_heartbeat.list", + "rlm_heartbeat.update", + ]); + expect(requests[2].payload).toMatchObject({ + type: "rlm_heartbeat.create", + interval: "5m", + label: "orchestrator", + }); + expect(requests[5].payload).toMatchObject({ + type: "rlm_heartbeat.update", + id: "job-orch", + interval: "10m", + label: "orchestrator", + }); + expect(requests[5].payload).not.toHaveProperty("status"); + expect(requests[8].payload).toMatchObject({ + type: "rlm_heartbeat.update", + id: "job-missing", + label: "missing-on-update", + }); + expect(requests[11].payload).toMatchObject({ + type: "rlm_heartbeat.update", + id: "job-paused", + label: "paused-orchestrator", + status: "resume", + }); + expect(requests[14].payload).toMatchObject({ + type: "rlm_heartbeat.update", + id: "job-orch", + label: "orchestrator", + }); + expect(requests[14].payload).not.toHaveProperty("interval"); + expect(requests[14].payload).not.toHaveProperty("status"); + expect(requests[17].payload).toMatchObject({ + type: "rlm_heartbeat.update", + id: "job-plain", + label: "plain-schedule", + }); + expect(requests[17].payload).not.toHaveProperty("interval"); + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts b/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts index af211b9fc1..dbb19330ad 100644 --- a/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts +++ b/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts @@ -4,7 +4,7 @@ import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; import type { BashOperations } from "../../src/core/tools/bash.js"; -import { createHarness, type Harness } from "./harness.js"; +import { createHarness, getMessageText, type Harness } from "./harness.js"; function getEntryTypes(harness: Harness): string[] { return harness.sessionManager.getEntries().map((entry) => entry.type); @@ -335,6 +335,131 @@ describe("AgentSession bash and persistence characterization", () => { expect(runningAtBashEnd).toBe(false); }); + it("drains queued agent-message prompts after user bash finishes", async () => { + let releaseBash: (() => void) | undefined; + let bashStarted: (() => void) | undefined; + const bashStartedPromise = new Promise((resolve) => { + bashStarted = resolve; + }); + const operations: BashOperations = { + exec: async (_command, _cwd, options) => { + bashStarted?.(); + options.onData(Buffer.from("bash output")); + await new Promise((resolve) => { + releaseBash = resolve; + }); + return { exitCode: 0 }; + }, + }; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("user_bash", async () => ({ operations })); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("after bash")]); + const agentPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_bash\n\nqueued after bash"; + const delivery = harness.session.waitForAgentMessagePromptDelivery("agentmsg_bash"); + + const bashRun = harness.session.runUserBash("slow bash"); + await bashStartedPromise; + expect(harness.session.isBashRunning).toBe(true); + await harness.session.queueAgentMessagePrompt(agentPrompt, "followUp"); + expect(harness.session.pendingMessageCount).toBe(1); + + releaseBash?.(); + await bashRun; + for (let i = 0; i < 10 && harness.session.pendingMessageCount > 0; i++) { + await Promise.resolve(); + } + await delivery; + + expect(harness.session.pendingMessageCount).toBe(0); + }); + + it("drains steering before follow-up prompts after user bash finishes", async () => { + let releaseBash: (() => void) | undefined; + let bashStarted: (() => void) | undefined; + const bashStartedPromise = new Promise((resolve) => { + bashStarted = resolve; + }); + const operations: BashOperations = { + exec: async (_command, _cwd, options) => { + bashStarted?.(); + options.onData(Buffer.from("bash output")); + await new Promise((resolve) => { + releaseBash = resolve; + }); + return { exitCode: 0 }; + }, + }; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("user_bash", async () => ({ operations })); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("after steer"), fauxAssistantMessage("after follow-up")]); + const steerPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_bash_steer\n\nsteer after bash"; + const followUpPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_bash_followup\n\nfollow-up after bash"; + const steerDelivery = harness.session.waitForAgentMessagePromptDelivery("agentmsg_bash_steer"); + const followUpDelivery = harness.session.waitForAgentMessagePromptDelivery("agentmsg_bash_followup"); + + const bashRun = harness.session.runUserBash("slow bash"); + await bashStartedPromise; + await harness.session.queueAgentMessagePrompt(followUpPrompt, "followUp"); + await harness.session.queueAgentMessagePrompt(steerPrompt, "steer"); + + releaseBash?.(); + await bashRun; + await steerDelivery; + await followUpDelivery; + + const userTexts = harness.session.messages.filter((message) => message.role === "user").map(getMessageText); + expect(userTexts.findIndex((text) => text.includes("steer after bash"))).toBeLessThan( + userTexts.findIndex((text) => text.includes("follow-up after bash")), + ); + expect(harness.session.pendingMessageCount).toBe(0); + }); + + it("flushes pending bash output before draining queued prompts", async () => { + const harness = await createHarness(); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("after bash")]); + const agentPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_flush_bash\n\nqueued after bash"; + const delivery = harness.session.waitForAgentMessagePromptDelivery("agentmsg_flush_bash"); + (harness.session.agent.state as { isStreaming: boolean }).isStreaming = true; + harness.session.recordBashResult("echo flushed", { + output: "flushed output", + exitCode: 0, + cancelled: false, + truncated: false, + }); + (harness.session.agent.state as { isStreaming: boolean }).isStreaming = false; + await harness.session.queueAgentMessagePrompt(agentPrompt, "followUp"); + + await ( + harness.session as unknown as { _drainQueuedMessagesAfterBash(): Promise } + )._drainQueuedMessagesAfterBash(); + await delivery; + + const bashIndex = harness.session.messages.findIndex((message) => message.role === "bashExecution"); + const userIndex = harness.session.messages.findIndex( + (message) => message.role === "user" && getMessageText(message).includes("queued after bash"), + ); + expect(bashIndex).toBeGreaterThanOrEqual(0); + expect(userIndex).toBeGreaterThan(bashIndex); + expect(harness.session.hasPendingBashMessages).toBe(false); + }); + it("rejects a second runUserBash issued before the first starts executing", async () => { const harness = await createHarness(); harnesses.push(harness); diff --git a/packages/coding-agent/test/suite/agent-session-observe.test.ts b/packages/coding-agent/test/suite/agent-session-observe.test.ts new file mode 100644 index 0000000000..cfec58f113 --- /dev/null +++ b/packages/coding-agent/test/suite/agent-session-observe.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentObserveController } from "../../src/core/agent-observe.js"; +import { createHarness } from "./harness.js"; + +function createController(): AgentObserveController { + return { + listAgents: vi.fn(() => ({ + current: { + activeSessionId: "alpha", + sessionId: "session-alpha", + cwd: "/tmp/project", + status: "idle", + isCurrent: true, + isStreaming: false, + isCompacting: false, + attachedClients: 0, + messageCount: 1, + pendingMessageCount: 0, + }, + agents: [], + })), + getAgent: vi.fn((target) => ({ + agent: { + activeSessionId: target, + sessionId: "session-beta", + cwd: "/tmp/project", + status: "model", + isCurrent: false, + isStreaming: true, + isCompacting: false, + attachedClients: 1, + messageCount: 3, + pendingMessageCount: 0, + }, + })), + recentMessages: vi.fn((input) => ({ + agent: { + activeSessionId: input.target, + sessionId: "session-beta", + cwd: "/tmp/project", + status: "model", + isCurrent: false, + isStreaming: true, + isCompacting: false, + attachedClients: 1, + messageCount: 3, + pendingMessageCount: 0, + }, + messages: [{ index: 2, role: "assistant", text: "working", truncated: false }], + limit: input.limit ?? 8, + maxChars: input.maxChars ?? 800, + truncated: false, + })), + }; +} + +describe("AgentSession agent observe host requests", () => { + it("routes list, get, and recent requests to the read-only controller", async () => { + const controller = createController(); + const harness = await createHarness({ agentObserveController: controller }); + try { + expect(harness.session.handleAgentObserveHostRequest("agent_observe.list")).toMatchObject({ + current: { activeSessionId: "alpha" }, + }); + expect(harness.session.handleAgentObserveHostRequest("agent_observe.get", { target: "beta" })).toMatchObject({ + agent: { activeSessionId: "beta", status: "model" }, + }); + expect( + harness.session.handleAgentObserveHostRequest("agent_observe.recent", { + target: "beta", + limit: 3, + max_chars: 120, + }), + ).toMatchObject({ + agent: { activeSessionId: "beta" }, + messages: [{ text: "working" }], + limit: 3, + maxChars: 120, + }); + expect(controller.listAgents).toHaveBeenCalledTimes(1); + expect(controller.getAgent).toHaveBeenCalledWith("beta"); + expect(controller.recentMessages).toHaveBeenCalledWith({ target: "beta", limit: 3, maxChars: 120 }); + } finally { + harness.cleanup(); + } + }); + + it("rejects malformed and unknown observe requests", async () => { + const harness = await createHarness({ agentObserveController: createController() }); + try { + expect(() => harness.session.handleAgentObserveHostRequest("agent_observe.get", {})).toThrow( + "target must be a string", + ); + expect(() => + harness.session.handleAgentObserveHostRequest("agent_observe.recent", { + target: "beta", + limit: 0, + }), + ).toThrow("between 1 and 50"); + expect(() => harness.session.handleAgentObserveHostRequest("agent_observe.delete")).toThrow( + "unknown agent observe request", + ); + } finally { + harness.cleanup(); + } + }); + + it("is unavailable without a daemon-backed controller", async () => { + const harness = await createHarness(); + try { + expect(() => harness.session.handleAgentObserveHostRequest("agent_observe.list")).toThrow( + "agent observation is not available", + ); + } finally { + harness.cleanup(); + } + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-prompt.test.ts b/packages/coding-agent/test/suite/agent-session-prompt.test.ts index e4ea1253eb..4dce87d1f7 100644 --- a/packages/coding-agent/test/suite/agent-session-prompt.test.ts +++ b/packages/coding-agent/test/suite/agent-session-prompt.test.ts @@ -5,10 +5,11 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; +import type { BashResult } from "../../src/core/bash-executor.js"; import type { PromptTemplate } from "../../src/core/prompt-templates.js"; import { createSyntheticSourceInfo } from "../../src/core/source-info.js"; import { createTestResourceLoader } from "../utilities.js"; -import { createHarness, getMessageText, type Harness } from "./harness.js"; +import { createHarness, getAssistantTexts, getMessageText, getUserTexts, type Harness } from "./harness.js"; describe("AgentSession prompt characterization", () => { const harnesses: Harness[] = []; @@ -305,6 +306,508 @@ describe("AgentSession prompt characterization", () => { await promptPromise; }); + it("resets stale extension system prompt for accepted agent messages", async () => { + const harness = await createHarness({ + systemPrompt: "base prompt", + extensionFactories: [ + (pi) => { + pi.on("before_agent_start", async (event) => ({ + systemPrompt: `${event.systemPrompt} + +stale extension instructions`, + })); + }, + ], + }); + harnesses.push(harness); + const baseSystemPrompt = harness.session.systemPrompt; + const providerSystemPrompts: string[] = []; + harness.setResponses([ + (context) => { + providerSystemPrompts.push(context.systemPrompt ?? ""); + return fauxAssistantMessage("first"); + }, + (context) => { + providerSystemPrompts.push(context.systemPrompt ?? ""); + return fauxAssistantMessage("second"); + }, + ]); + + await harness.session.prompt("normal prompt"); + await harness.session.acceptAgentMessagePrompt("agent-to-agent payload", { expandPromptTemplates: false }); + await harness.session.agent.waitForIdle(); + + expect(providerSystemPrompts[0]).toContain("stale extension instructions"); + expect(providerSystemPrompts[1]).toBe(baseSystemPrompt); + expect(providerSystemPrompts[1]).not.toContain("stale extension instructions"); + }); + + it("queues accepted agent messages while compacting", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const sessionInternals = harness.session as unknown as { + _compactionAbortController?: AbortController; + }; + sessionInternals._compactionAbortController = new AbortController(); + + await harness.session.acceptAgentMessagePrompt("agent-to-agent payload", { + expandPromptTemplates: false, + streamingBehavior: "followUp", + queueIfBusy: true, + }); + + expect(harness.session.getFollowUpMessages()).toEqual(["agent-to-agent payload"]); + expect(harness.getPendingResponseCount()).toBe(0); + }); + + it("folds pending nextTurn context into accepted agent messages queued while busy", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const agentPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_next_turn_queued\n\nagent text"; + await harness.session.sendCustomMessage( + { customType: "next-turn", content: "queued context", display: true, details: {} }, + { deliverAs: "nextTurn" }, + ); + const sessionInternals = harness.session as unknown as { + _compactionAbortController?: AbortController; + }; + sessionInternals._compactionAbortController = new AbortController(); + + await harness.session.acceptAgentMessagePrompt(agentPrompt, { + expandPromptTemplates: false, + streamingBehavior: "followUp", + queueIfBusy: true, + }); + expect(harness.session.getFollowUpMessages()).toEqual([agentPrompt]); + + sessionInternals._compactionAbortController = undefined; + let queuedTurnSawNextTurnContext = false; + harness.setResponses([ + fauxAssistantMessage("first turn"), + (context) => { + const queuedUser = context.messages.find( + (message) => message.role === "user" && getMessageText(message).includes("agentmsg_next_turn_queued"), + ); + queuedTurnSawNextTurnContext = queuedUser ? getMessageText(queuedUser).includes("queued context") : false; + return fauxAssistantMessage("queued turn"); + }, + ]); + + await harness.session.prompt("normal prompt"); + + expect(queuedTurnSawNextTurnContext).toBe(true); + expect(harness.session.pendingMessageCount).toBe(0); + }); + + it("queues accepted agent messages if the session becomes busy before handoff", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const agentPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_handoff_busy\n\nqueue at handoff"; + let releaseRefine: (() => void) | undefined; + const refineGate = new Promise((resolve) => { + releaseRefine = resolve; + }); + const sessionInternals = harness.session as unknown as { + _refineInFlight?: Promise; + _userBashRunning?: boolean; + }; + sessionInternals._refineInFlight = refineGate; + + const accepted = harness.session.acceptAgentMessagePrompt(agentPrompt, { + expandPromptTemplates: false, + streamingBehavior: "followUp", + queueIfBusy: true, + }); + await Promise.resolve(); + sessionInternals._userBashRunning = true; + sessionInternals._refineInFlight = undefined; + releaseRefine?.(); + + await accepted; + sessionInternals._userBashRunning = false; + + expect(harness.session.getFollowUpMessages()).toEqual([agentPrompt]); + expect(harness.getPendingResponseCount()).toBe(0); + }); + + it("restores nextTurn context when handoff busy rejection cannot queue", async () => { + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.sendCustomMessage( + { customType: "next-turn", content: "restore after handoff failure", display: true, details: {} }, + { deliverAs: "nextTurn" }, + ); + let releaseRefine: (() => void) | undefined; + const refineGate = new Promise((resolve) => { + releaseRefine = resolve; + }); + const sessionInternals = harness.session as unknown as { + _refineInFlight?: Promise; + _userBashRunning?: boolean; + }; + sessionInternals._refineInFlight = refineGate; + + const accepted = harness.session.acceptAgentMessagePrompt( + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_handoff_reject\n\nagent text", + { expandPromptTemplates: false, queueIfBusy: true }, + ); + await Promise.resolve(); + sessionInternals._userBashRunning = true; + sessionInternals._refineInFlight = undefined; + releaseRefine?.(); + + await expect(accepted).rejects.toThrow("Agent became busy before prompt delivery"); + sessionInternals._userBashRunning = false; + + let sawRestoredContext = false; + harness.setResponses([ + (context) => { + sawRestoredContext = context.messages.some( + (message) => + message.role === "user" && getMessageText(message).includes("restore after handoff failure"), + ); + return fauxAssistantMessage("done"); + }, + ]); + await harness.session.prompt("normal prompt"); + + expect(sawRestoredContext).toBe(true); + }); + + it("accepted agent messages return after delivery starts, before completion", async () => { + const harness = await createHarness({ models: [{ id: "slow-faux" }] }); + harnesses.push(harness); + const agentPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_after_preflight\n\nagent text"; + let releaseResponse: (() => void) | undefined; + const responseGate = new Promise((resolve) => { + releaseResponse = resolve; + }); + harness.setResponses([ + async () => { + await responseGate; + return fauxAssistantMessage("delivered"); + }, + ]); + + await harness.session.acceptAgentMessagePrompt(agentPrompt, { expandPromptTemplates: false }); + expect(getUserTexts(harness)).toEqual([agentPrompt]); + expect(getAssistantTexts(harness)).toEqual([]); + expect(harness.session.clearQueuedUserMessagesMatching((text) => text.includes("agentmsg_"))).toEqual({ + steering: [], + followUp: [], + }); + releaseResponse?.(); + await harness.session.agent.waitForIdle(); + + expect(getUserTexts(harness)).toEqual([agentPrompt]); + expect(getAssistantTexts(harness)).toEqual(["delivered"]); + expect(harness.session.getFollowUpMessages()).toEqual([]); + }); + + it("allows normal prompts while an accepted agent message is idle between retry attempts", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const acceptedMessage = { + role: "user" as const, + content: [{ type: "text" as const, text: "accepted agent message" }], + timestamp: Date.now(), + }; + const sessionInternals = harness.session as unknown as { + _acceptedAgentMessagePrompt?: { + text: string; + agentMessageId: string; + message: typeof acceptedMessage; + messages: Set; + pendingNextTurnMessages: unknown[]; + accepted: Promise; + resolveAccepted: () => void; + rejectAccepted: (error: Error) => void; + turnStarted: boolean; + cleared: boolean; + }; + }; + harness.setResponses([fauxAssistantMessage("ordinary response")]); + + sessionInternals._acceptedAgentMessagePrompt = { + text: "accepted agent message", + agentMessageId: "agentmsg_in_flight", + message: acceptedMessage, + messages: new Set([acceptedMessage]), + pendingNextTurnMessages: [], + accepted: Promise.resolve(), + resolveAccepted: () => {}, + rejectAccepted: () => {}, + turnStarted: true, + cleared: false, + }; + await expect(harness.session.prompt("ordinary prompt")).resolves.toBeUndefined(); + sessionInternals._acceptedAgentMessagePrompt = undefined; + expect(getUserTexts(harness)).toContain("ordinary prompt"); + }); + + it("flushes pending bash messages before accepted agent messages", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const sessionInternals = harness.session as unknown as { + recordBashResult(command: string, result: BashResult): void; + _flushPendingBashMessages(): void; + }; + const contextRoles: string[][] = []; + const contextTexts: string[][] = []; + harness.setResponses([ + fauxAssistantMessage("busy done"), + (context) => { + contextRoles.push(context.messages.map((message) => message.role)); + contextTexts.push(context.messages.map((message) => getMessageText(message))); + return fauxAssistantMessage("agent message response"); + }, + ]); + + const busyPrompt = harness.session.agent.prompt("busy"); + sessionInternals.recordBashResult("echo hi", { + output: "hi", + exitCode: 0, + cancelled: false, + truncated: false, + }); + await busyPrompt; + await harness.session.acceptAgentMessagePrompt("agent-to-agent payload", { expandPromptTemplates: false }); + await harness.session.agent.waitForIdle(); + + expect(contextRoles).toEqual([["user", "assistant", "user", "user"]]); + expect(contextTexts[0]?.[2]).toContain("Ran `echo hi`"); + expect(contextTexts[0]?.[3]).toBe("agent-to-agent payload"); + expect(harness.session.hasPendingBashMessages).toBe(false); + expect(typeof sessionInternals._flushPendingBashMessages).toBe("function"); + }); + + it("does not clear accepted agent messages after delivery starts", async () => { + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("message_end", async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }, + ], + }); + harnesses.push(harness); + const agentPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_after_assistant\n\nagent text"; + harness.setResponses([fauxAssistantMessage("delivered assistant response")]); + + await harness.session.acceptAgentMessagePrompt(agentPrompt, { expandPromptTemplates: false }); + await Promise.resolve(); + expect(harness.session.clearQueuedUserMessagesMatching((text) => text.includes("agentmsg_"))).toEqual({ + steering: [], + followUp: [], + }); + await harness.session.agent.waitForIdle(); + + expect(getUserTexts(harness)).toEqual([agentPrompt]); + expect(getAssistantTexts(harness)).toEqual(["delivered assistant response"]); + }); + + it("cleared accepted agent message cleanup does not remove messages from a newer prompt", async () => { + let gateAgentStart = false; + let releaseGate = () => {}; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("agent_start", async () => { + if (gateAgentStart) { + await gate; + } + }); + }, + ], + }); + harnesses.push(harness); + const agentPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_stale_cleanup\n\nagent text"; + harness.setResponses([fauxAssistantMessage("seed response")]); + await harness.session.prompt("seed"); + + harness.setResponses([fauxAssistantMessage("never delivered"), fauxAssistantMessage("after clear response")]); + gateAgentStart = true; + const accepted = harness.session.acceptAgentMessagePrompt(agentPrompt, { expandPromptTemplates: false }); + expect(harness.session.clearQueuedUserMessagesMatching((text) => text.includes("agentmsg_"))).toEqual({ + steering: [], + followUp: [agentPrompt], + }); + await expect(accepted).rejects.toThrow("cleared before delivery"); + await harness.session.agent.waitForIdle(); + + // The cleared run's events are still stalled in the session event queue; + // run a newer prompt so its messages land in state before the stale cleanup. + gateAgentStart = false; + await harness.session.prompt("after clear"); + releaseGate(); + await (harness.session as unknown as { _agentEventQueue: Promise })._agentEventQueue; + + expect(getUserTexts(harness)).toEqual(["seed", "after clear"]); + expect(getAssistantTexts(harness)).toEqual(["seed response", "after clear response"]); + }); + + it("restores drained nextTurn messages when an accepted agent message is cleared", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const agentPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_next_turn\n\nagent text"; + await harness.session.sendCustomMessage( + { customType: "next-turn", content: "carry this", display: true, details: {} }, + { deliverAs: "nextTurn" }, + ); + harness.setResponses([fauxAssistantMessage("never delivered")]); + + const accepted = harness.session.acceptAgentMessagePrompt(agentPrompt, { expandPromptTemplates: false }); + expect(harness.session.clearQueuedUserMessagesMatching((text) => text.includes("agentmsg_"))).toEqual({ + steering: [], + followUp: [agentPrompt], + }); + await expect(accepted).rejects.toThrow("cleared before delivery"); + await harness.session.agent.waitForIdle(); + await (harness.session as unknown as { _agentEventQueue: Promise })._agentEventQueue; + + let sawCustomMessage = false; + harness.setResponses([ + (context) => { + sawCustomMessage = context.messages.some( + (message) => + message.role === "user" && + typeof message.content !== "string" && + message.content.some((part) => part.type === "text" && part.text === "carry this"), + ); + return fauxAssistantMessage("done"); + }, + ]); + await harness.session.prompt("normal prompt"); + + expect(sawCustomMessage).toBe(true); + expect(harness.session.messages.map((message) => message.role)).toEqual(["custom", "user", "assistant"]); + }); + + it("restores drained nextTurn messages when direct agent message acceptance fails before delivery", async () => { + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.sendCustomMessage( + { customType: "next-turn", content: "retry me", display: true, details: {} }, + { deliverAs: "nextTurn" }, + ); + const agent = harness.session.agent as unknown as { prompt(messages: unknown): Promise }; + const originalPrompt = agent.prompt; + agent.prompt = async () => { + throw new Error("prompt failed before delivery"); + }; + + await expect( + harness.session.acceptAgentMessagePrompt( + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_context_fail\n\nagent text", + { expandPromptTemplates: false }, + ), + ).rejects.toThrow("prompt failed before delivery"); + agent.prompt = originalPrompt; + + let sawCustomMessage = false; + harness.setResponses([ + (context) => { + sawCustomMessage = context.messages.some( + (message) => + message.role === "user" && + typeof message.content !== "string" && + message.content.some((part) => part.type === "text" && part.text === "retry me"), + ); + return fauxAssistantMessage("done"); + }, + ]); + await harness.session.prompt("normal prompt"); + + expect(sawCustomMessage).toBe(true); + }); + + it("cleans up the aborted run's late events after clearing an accepted agent message", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const agentPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_late_events\n\nagent text"; + harness.setResponses([fauxAssistantMessage("seed response")]); + await harness.session.prompt("seed"); + harness.setResponses([fauxAssistantMessage("never delivered")]); + + const delivery = harness.session.waitForAgentMessagePromptDelivery("agentmsg_late_events"); + const accepted = harness.session.acceptAgentMessagePrompt(agentPrompt, { expandPromptTemplates: false }); + expect(harness.session.clearQueuedUserMessagesMatching((text) => text.includes("agentmsg_"))).toEqual({ + steering: [], + followUp: [agentPrompt], + }); + await expect(accepted).rejects.toThrow("cleared before delivery"); + await expect(delivery).rejects.toThrow("cleared before delivery"); + await harness.session.agent.waitForIdle(); + await (harness.session as unknown as { _agentEventQueue: Promise })._agentEventQueue; + + await expect(harness.session.waitForAgentMessagePromptDelivery("agentmsg_late_events")).rejects.toThrow( + "cleared before delivery", + ); + // The aborted run's late message events must not re-persist the cleared message. + const persistedRoles = harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "message") + .map((entry) => entry.message.role); + expect(persistedRoles).toEqual(["user", "assistant"]); + expect(getUserTexts(harness)).toEqual(["seed"]); + expect(getAssistantTexts(harness)).toEqual(["seed response"]); + expect(harness.session.agent.state.errorMessage).toBeUndefined(); + expect( + (harness.session as unknown as { _acceptedAgentMessagePrompt?: unknown })._acceptedAgentMessagePrompt, + ).toBeUndefined(); + }); + + it("queues accepted agent messages without expanding slash commands or prompt templates", async () => { + const template: PromptTemplate = { + name: "review", + description: "Review template", + content: "expanded template: $1", + filePath: "/virtual/review.md", + sourceInfo: createSyntheticSourceInfo("/virtual/review.md", { + source: "local", + scope: "temporary", + origin: "top-level", + }), + }; + const resourceLoader = { + ...createTestResourceLoader(), + getPrompts: () => ({ prompts: [template], diagnostics: [] }), + }; + const commandRuns: string[] = []; + const harness = await createHarness({ + resourceLoader, + extensionFactories: [ + (pi) => { + pi.registerCommand("testcmd", { + description: "Test command", + handler: async (args) => { + commandRuns.push(args); + }, + }); + }, + ], + }); + harnesses.push(harness); + + await expect(harness.session.queueAgentMessagePrompt("/review keep literal", "followUp")).resolves.toBe(true); + await expect(harness.session.queueAgentMessagePrompt("/testcmd keep literal", "followUp")).resolves.toBe(true); + + expect(harness.session.getFollowUpMessages()).toEqual(["/review keep literal", "/testcmd keep literal"]); + expect(commandRuns).toEqual([]); + expect(harness.getPendingResponseCount()).toBe(0); + }); + it("throws when prompting without a model", async () => { const harness = await createHarness(); harnesses.push(harness); diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 2f8f71a600..7a204020a9 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -1413,6 +1413,33 @@ describe("AgentSession queue characterization", () => { await promptPromise; }); + it("reports failed preflight when a duplicate follow-up queue key is not queued", async () => { + const waiting = await createWaitingHarness(); + const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting; + harnesses.push(harness); + const preflightResults: boolean[] = []; + + harness.setResponses([ + fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("done"), + ]); + await waitForToolStart; + await harness.session.prompt("heartbeat", { + streamingBehavior: "followUp", + followUpQueueKey: "heartbeat:one", + }); + await harness.session.prompt("heartbeat", { + streamingBehavior: "followUp", + followUpQueueKey: "heartbeat:one", + preflightResult: (didSucceed) => preflightResults.push(didSucceed), + }); + + expect(preflightResults).toEqual([false]); + expect(harness.session.getFollowUpMessages()).toEqual(["heartbeat"]); + releaseToolExecution(); + await promptPromise; + }); + it("keeps separate follow-up messages for different queue keys", async () => { const waiting = await createWaitingHarness(); const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting; @@ -1751,6 +1778,166 @@ describe("AgentSession queue characterization", () => { expect(harness.session.pendingMessageCount).toBe(0); }); + it("clears only internally queued agent-message prompts", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const spoofed = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_spoof\n\nordinary user text"; + const real = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_real\n\nreal agent text"; + + await harness.session.followUp(spoofed); + await harness.session.queueAgentMessagePrompt(real, "followUp"); + + expect(harness.session.clearQueuedUserMessagesMatching((text) => text.includes("agentmsg_"))).toEqual({ + steering: [], + followUp: [real], + }); + expect(harness.session.getFollowUpMessages()).toEqual([spoofed]); + }); + + it("clears internally queued agent-message steering prompts by message identity", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const sharedText = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_shared\n\nshared text"; + + await harness.session.steer(sharedText); + await harness.session.queueAgentMessagePrompt(sharedText, "steer"); + + expect(harness.session.clearQueuedUserMessagesMatching((text) => text.includes("agentmsg_"))).toEqual({ + steering: [sharedText], + followUp: [], + }); + expect(harness.session.getSteeringMessages()).toEqual([sharedText]); + }); + + it("clears the agent queue when a queue update listener clears a newly queued steering prompt", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const agentPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_queue_update_clear\n\nclear during update"; + const delivery = harness.session.waitForAgentMessagePromptDelivery("agentmsg_queue_update_clear"); + let cleared = false; + const unsubscribe = harness.session.subscribe((event) => { + if (event.type === "queue_update" && !cleared) { + cleared = true; + harness.session.clearQueue(); + } + }); + + await harness.session.queueAgentMessagePrompt(agentPrompt, "steer"); + unsubscribe(); + await expect(delivery).rejects.toThrow("cleared before delivery"); + expect(harness.session.getSteeringMessages()).toEqual([]); + + let sawClearedPrompt = false; + harness.setResponses([ + (context) => { + sawClearedPrompt = context.messages.some( + (message) => message.role === "user" && getMessageText(message).includes("agentmsg_queue_update_clear"), + ); + return fauxAssistantMessage("normal response"); + }, + ]); + await harness.session.prompt("normal"); + + expect(sawClearedPrompt).toBe(false); + expect(getUserTexts(harness)).toEqual(["normal"]); + }); + + it("settles late agent-message delivery waiters", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const deliveryInternals = harness.session as unknown as { + waitForAgentMessagePromptDelivery(agentMessageId: string): Promise; + _resolveAgentMessageDelivery(agentMessageId: string): void; + _rejectAgentMessageDelivery(agentMessageId: string, error: Error): void; + }; + + deliveryInternals._resolveAgentMessageDelivery("agentmsg_delivered"); + await expect(deliveryInternals.waitForAgentMessagePromptDelivery("agentmsg_delivered")).resolves.toBeUndefined(); + + deliveryInternals._rejectAgentMessageDelivery("agentmsg_failed", new Error("cleared before delivery")); + await expect(deliveryInternals.waitForAgentMessagePromptDelivery("agentmsg_failed")).rejects.toThrow( + "cleared before delivery", + ); + }); + + it("keeps queued agent-message delivery waiters pending on abort until the message is delivered", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const agentPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_abort\n\nsurvive the abort"; + harness.setResponses([fauxAssistantMessage("x".repeat(20_000))]); + + const sawMessageUpdate = new Promise((resolve) => { + const unsubscribe = harness.session.subscribe((event) => { + if (event.type === "message_update") { + unsubscribe(); + resolve(); + } + }); + }); + const promptPromise = harness.session.prompt("hi"); + await sawMessageUpdate; + + await harness.session.queueAgentMessagePrompt(agentPrompt, "followUp"); + const delivery = harness.session.waitForAgentMessagePromptDelivery("agentmsg_abort"); + let deliverySettled = false; + void delivery.then( + () => { + deliverySettled = true; + }, + () => { + deliverySettled = true; + }, + ); + expect(harness.session.pendingMessageCount).toBe(1); + + await harness.session.abort(); + await promptPromise; + await Promise.resolve(); + + // The waiter still represents actual delivery, and the surviving queued message has not delivered yet. + expect(deliverySettled).toBe(false); + expect(harness.session.pendingMessageCount).toBe(1); + expect(harness.session.getFollowUpMessages()).toEqual([agentPrompt]); + + harness.setResponses([fauxAssistantMessage("answer"), fauxAssistantMessage("handled follow-up")]); + await harness.session.prompt("again"); + + await expect(delivery).resolves.toBeUndefined(); + expect(harness.session.pendingMessageCount).toBe(0); + expect(getUserTexts(harness)).toContain(agentPrompt); + }); + + it("resolves direct agent-message delivery waiters when the accepted prompt starts", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const agentPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_direct\n\ndirect delivery"; + harness.setResponses([fauxAssistantMessage("direct reply")]); + + const delivery = harness.session.waitForAgentMessagePromptDelivery("agentmsg_direct"); + await harness.session.acceptAgentMessagePrompt(agentPrompt); + + await expect(delivery).resolves.toBeUndefined(); + }); + + it("rejects queued agent-message delivery waiters on dispose", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const agentPrompt = + "Agent-to-agent message received.\nSource: agent_message\nTo: Target, active target, session session-target\nMessage id: agentmsg_dispose\n\ndispose me"; + const delivery = harness.session.waitForAgentMessagePromptDelivery("agentmsg_dispose"); + + await harness.session.queueAgentMessagePrompt(agentPrompt, "followUp"); + harness.session.dispose(); + + await expect(delivery).rejects.toThrow("cleared before delivery"); + }); + it("throws when queueing an extension command with steer", async () => { const harness = await createHarness({ extensionFactories: [ diff --git a/packages/coding-agent/test/suite/agent-session-retry-events.test.ts b/packages/coding-agent/test/suite/agent-session-retry-events.test.ts index d10fe90c5a..a6d61b3d67 100644 --- a/packages/coding-agent/test/suite/agent-session-retry-events.test.ts +++ b/packages/coding-agent/test/suite/agent-session-retry-events.test.ts @@ -119,6 +119,33 @@ describe("AgentSession retry and event characterization", () => { expect(harness.session.isRetrying).toBe(false); }); + it("accepted agent message prompts keep retry state queued after returning", async () => { + const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 40 } } }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }), + fauxAssistantMessage("recovered"), + ]); + const sawRetryStart = new Promise((resolve) => { + const unsubscribe = harness.session.subscribe((event) => { + if (event.type === "auto_retry_start") { + unsubscribe(); + resolve(); + } + }); + }); + + await harness.session.acceptAgentMessagePrompt("agent-to-agent payload", { expandPromptTemplates: false }); + await sawRetryStart; + + expect(harness.session.isRetrying).toBe(true); + expect(harness.session.hasAcceptedPromptInFlight).toBe(true); + await expect( + harness.session.prompt("second", { queueIfBusy: true, streamingBehavior: "followUp" }), + ).resolves.toBeUndefined(); + expect(harness.session.pendingMessageCount).toBe(1); + }); + it("does not retry when retry is disabled", async () => { const harness = await createHarness({ settings: { retry: { enabled: false } } }); harnesses.push(harness); diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index d72c4faa74..2df95bf0cf 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -9,6 +9,7 @@ import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; import { Agent } from "@earendil-works/pi-agent-core"; import type { FauxModelDefinition, FauxProviderRegistration, FauxResponseStep, Model } from "@earendil-works/pi-ai"; import { registerFauxProvider } from "@earendil-works/pi-ai"; +import type { AgentObserveController } from "../../src/core/agent-observe.js"; import { AgentSession, type AgentSessionEvent, type AutoRefineReviewer } from "../../src/core/agent-session.js"; import { AuthStorage } from "../../src/core/auth-storage.js"; import type { ExtensionRunner } from "../../src/core/extensions/index.js"; @@ -63,6 +64,7 @@ export interface HarnessOptions { resourceLoader?: ResourceLoader; extensionFactories?: Array; withConfiguredAuth?: boolean; + agentObserveController?: AgentObserveController; persistSession?: boolean; rlmDepth?: number; autoRefineReviewer?: AutoRefineReviewer; @@ -177,6 +179,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise