mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
Merge remote-tracking branch 'origin/main' into hermes/hermes-e0e71a89
This commit is contained in:
commit
2001b88c23
13 changed files with 1161 additions and 9 deletions
17
cron/jobs.py
17
cron/jobs.py
|
|
@ -431,8 +431,19 @@ def save_job_output(job_id: str, output: str):
|
|||
timestamp = _hermes_now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
output_file = job_output_dir / f"{timestamp}.md"
|
||||
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(output)
|
||||
_secure_file(output_file)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(job_output_dir), suffix='.tmp', prefix='.output_')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
f.write(output)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, output_file)
|
||||
_secure_file(output_file)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
return output_file
|
||||
|
|
|
|||
|
|
@ -304,6 +304,8 @@ def load_gateway_config() -> GatewayConfig:
|
|||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
||||
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ from typing import Dict, List, Optional, Any
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080}
|
||||
|
||||
try:
|
||||
import discord
|
||||
from discord import Message as DiscordMessage, Intents
|
||||
|
|
@ -251,6 +253,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
audio_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send audio as a Discord file attachment."""
|
||||
if not self._client:
|
||||
|
|
@ -289,6 +292,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
image_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a local image file natively as a Discord file attachment."""
|
||||
if not self._client:
|
||||
|
|
@ -326,6 +330,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
image_url: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an image natively as a Discord file attachment."""
|
||||
if not self._client:
|
||||
|
|
@ -711,6 +716,21 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
except Exception as e:
|
||||
logger.debug("Discord followup failed: %s", e)
|
||||
|
||||
@tree.command(name="thread", description="Create a new thread and start a Hermes session in it")
|
||||
@discord.app_commands.describe(
|
||||
name="Thread name",
|
||||
message="Optional first message to send to Hermes in the thread",
|
||||
auto_archive_duration="Auto-archive in minutes (60, 1440, 4320, 10080)",
|
||||
)
|
||||
async def slash_thread(
|
||||
interaction: discord.Interaction,
|
||||
name: str,
|
||||
message: str = "",
|
||||
auto_archive_duration: int = 1440,
|
||||
):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
await self._handle_thread_create_slash(interaction, name, message, auto_archive_duration)
|
||||
|
||||
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
|
||||
"""Build a MessageEvent from a Discord slash command interaction."""
|
||||
is_dm = isinstance(interaction.channel, discord.DMChannel)
|
||||
|
|
@ -741,6 +761,188 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
raw_message=interaction,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Thread creation helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _handle_thread_create_slash(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
name: str,
|
||||
message: str = "",
|
||||
auto_archive_duration: int = 1440,
|
||||
) -> None:
|
||||
"""Create a Discord thread from a slash command and start a session in it."""
|
||||
result = await self._create_thread(
|
||||
interaction,
|
||||
name=name,
|
||||
message=message,
|
||||
auto_archive_duration=auto_archive_duration,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
error = result.get("error", "unknown error")
|
||||
await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True)
|
||||
return
|
||||
|
||||
thread_id = result.get("thread_id")
|
||||
thread_name = result.get("thread_name") or name
|
||||
|
||||
# Tell the user where the thread is
|
||||
link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**"
|
||||
await interaction.followup.send(f"Created thread {link}", ephemeral=True)
|
||||
|
||||
# If a message was provided, kick off a new Hermes session in the thread
|
||||
starter = (message or "").strip()
|
||||
if starter and thread_id:
|
||||
await self._dispatch_thread_session(interaction, thread_id, thread_name, starter)
|
||||
|
||||
async def _dispatch_thread_session(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
thread_id: str,
|
||||
thread_name: str,
|
||||
text: str,
|
||||
) -> None:
|
||||
"""Build a MessageEvent pointing at a thread and send it through handle_message."""
|
||||
guild_name = ""
|
||||
if hasattr(interaction, "guild") and interaction.guild:
|
||||
guild_name = interaction.guild.name
|
||||
|
||||
chat_name = f"{guild_name} / {thread_name}" if guild_name else thread_name
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=thread_id,
|
||||
chat_name=chat_name,
|
||||
chat_type="thread",
|
||||
user_id=str(interaction.user.id),
|
||||
user_name=interaction.user.display_name,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
event = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
raw_message=interaction,
|
||||
)
|
||||
await self.handle_message(event)
|
||||
|
||||
def _thread_parent_channel(self, channel: Any) -> Any:
|
||||
"""Return the parent text channel when invoked from a thread."""
|
||||
return getattr(channel, "parent", None) or channel
|
||||
|
||||
async def _resolve_interaction_channel(self, interaction: discord.Interaction) -> Optional[Any]:
|
||||
"""Return the interaction channel, fetching it if the payload is partial."""
|
||||
channel = getattr(interaction, "channel", None)
|
||||
if channel is not None:
|
||||
return channel
|
||||
if not self._client:
|
||||
return None
|
||||
channel_id = getattr(interaction, "channel_id", None)
|
||||
if channel_id is None:
|
||||
return None
|
||||
channel = self._client.get_channel(int(channel_id))
|
||||
if channel is not None:
|
||||
return channel
|
||||
try:
|
||||
return await self._client.fetch_channel(int(channel_id))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _create_thread(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
*,
|
||||
name: str,
|
||||
message: str = "",
|
||||
auto_archive_duration: int = 1440,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a thread in the current Discord channel.
|
||||
|
||||
Tries ``parent_channel.create_thread()`` first. If Discord rejects
|
||||
that (e.g. permission issues), falls back to sending a seed message
|
||||
and creating the thread from it.
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return {"error": "Thread name is required."}
|
||||
|
||||
if auto_archive_duration not in VALID_THREAD_AUTO_ARCHIVE_MINUTES:
|
||||
allowed = ", ".join(str(v) for v in sorted(VALID_THREAD_AUTO_ARCHIVE_MINUTES))
|
||||
return {"error": f"auto_archive_duration must be one of: {allowed}."}
|
||||
|
||||
channel = await self._resolve_interaction_channel(interaction)
|
||||
if channel is None:
|
||||
return {"error": "Could not resolve the current Discord channel."}
|
||||
if isinstance(channel, discord.DMChannel):
|
||||
return {"error": "Discord threads can only be created inside server text channels, not DMs."}
|
||||
|
||||
parent_channel = self._thread_parent_channel(channel)
|
||||
if parent_channel is None:
|
||||
return {"error": "Could not determine a parent text channel for the new thread."}
|
||||
|
||||
display_name = getattr(getattr(interaction, "user", None), "display_name", None) or "unknown user"
|
||||
reason = f"Requested by {display_name} via /thread"
|
||||
starter_message = (message or "").strip()
|
||||
|
||||
try:
|
||||
thread = await parent_channel.create_thread(
|
||||
name=name,
|
||||
auto_archive_duration=auto_archive_duration,
|
||||
reason=reason,
|
||||
)
|
||||
if starter_message:
|
||||
await thread.send(starter_message)
|
||||
return {
|
||||
"success": True,
|
||||
"thread_id": str(thread.id),
|
||||
"thread_name": getattr(thread, "name", None) or name,
|
||||
}
|
||||
except Exception as direct_error:
|
||||
try:
|
||||
seed_content = starter_message or f"\U0001f9f5 Thread created by Hermes: **{name}**"
|
||||
seed_msg = await parent_channel.send(seed_content)
|
||||
thread = await seed_msg.create_thread(
|
||||
name=name,
|
||||
auto_archive_duration=auto_archive_duration,
|
||||
reason=reason,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"thread_id": str(thread.id),
|
||||
"thread_name": getattr(thread, "name", None) or name,
|
||||
}
|
||||
except Exception as fallback_error:
|
||||
return {
|
||||
"error": (
|
||||
"Discord rejected direct thread creation and the fallback also failed. "
|
||||
f"Direct error: {direct_error}. Fallback error: {fallback_error}"
|
||||
)
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-thread helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
|
||||
"""Create a thread from a user message for auto-threading.
|
||||
|
||||
Returns the created thread object, or ``None`` on failure.
|
||||
"""
|
||||
# Build a short thread name from the message
|
||||
content = (message.content or "").strip()
|
||||
thread_name = content[:80] if content else "Hermes"
|
||||
if len(content) > 80:
|
||||
thread_name = thread_name[:77] + "..."
|
||||
|
||||
try:
|
||||
thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
|
||||
return thread
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Auto-thread creation failed: %s", self.name, e)
|
||||
return None
|
||||
|
||||
async def send_exec_approval(
|
||||
self, chat_id: str, command: str, approval_id: str
|
||||
) -> SendResult:
|
||||
|
|
@ -852,6 +1054,19 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
message.content = message.content.replace(f"<@{self._client.user.id}>", "").strip()
|
||||
message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip()
|
||||
|
||||
# Auto-thread: when enabled, automatically create a thread for every
|
||||
# new message in a text channel so each conversation is isolated.
|
||||
# Messages already inside threads or DMs are unaffected.
|
||||
auto_threaded_channel = None
|
||||
if not is_thread and not isinstance(message.channel, discord.DMChannel):
|
||||
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "").lower() in ("true", "1", "yes")
|
||||
if auto_thread:
|
||||
thread = await self._auto_create_thread(message)
|
||||
if thread:
|
||||
is_thread = True
|
||||
thread_id = str(thread.id)
|
||||
auto_threaded_channel = thread
|
||||
|
||||
# Determine message type
|
||||
msg_type = MessageType.TEXT
|
||||
if message.content.startswith("/"):
|
||||
|
|
@ -870,13 +1085,16 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
msg_type = MessageType.DOCUMENT
|
||||
break
|
||||
|
||||
# When auto-threading kicked in, route responses to the new thread
|
||||
effective_channel = auto_threaded_channel or message.channel
|
||||
|
||||
# Determine chat type
|
||||
if isinstance(message.channel, discord.DMChannel):
|
||||
chat_type = "dm"
|
||||
chat_name = message.author.name
|
||||
elif is_thread:
|
||||
chat_type = "thread"
|
||||
chat_name = self._format_thread_chat_name(message.channel)
|
||||
chat_name = self._format_thread_chat_name(effective_channel)
|
||||
else:
|
||||
chat_type = "group"
|
||||
chat_name = getattr(message.channel, "name", str(message.channel.id))
|
||||
|
|
@ -888,7 +1106,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
|
||||
# Build source
|
||||
source = self.build_source(
|
||||
chat_id=str(message.channel.id),
|
||||
chat_id=str(effective_channel.id),
|
||||
chat_name=chat_name,
|
||||
chat_type=chat_type,
|
||||
user_id=str(message.author.id),
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ def check_info(text: str):
|
|||
def run_doctor(args):
|
||||
"""Run diagnostic checks."""
|
||||
should_fix = getattr(args, 'fix', False)
|
||||
|
||||
# Doctor runs from the interactive CLI, so CLI-gated tool availability
|
||||
# checks (like cronjob management) should see the same context as `hermes`.
|
||||
os.environ.setdefault("HERMES_INTERACTIVE", "1")
|
||||
|
||||
issues = []
|
||||
manual_issues = [] # issues that can't be auto-fixed
|
||||
|
|
|
|||
162
optional-skills/security/1password/SKILL.md
Normal file
162
optional-skills/security/1password/SKILL.md
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
---
|
||||
name: 1password
|
||||
description: Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in, and reading/injecting secrets for commands.
|
||||
version: 1.0.0
|
||||
author: arceus77-7, enhanced by Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [security, secrets, 1password, op, cli]
|
||||
category: security
|
||||
setup:
|
||||
help: "Create a service account at https://my.1password.com → Settings → Service Accounts"
|
||||
collect_secrets:
|
||||
- env_var: OP_SERVICE_ACCOUNT_TOKEN
|
||||
prompt: "1Password Service Account Token"
|
||||
provider_url: "https://developer.1password.com/docs/service-accounts/"
|
||||
secret: true
|
||||
---
|
||||
|
||||
# 1Password CLI
|
||||
|
||||
Use this skill when the user wants secrets managed through 1Password instead of plaintext env vars or files.
|
||||
|
||||
## Requirements
|
||||
|
||||
- 1Password account
|
||||
- 1Password CLI (`op`) installed
|
||||
- One of: desktop app integration, service account token (`OP_SERVICE_ACCOUNT_TOKEN`), or Connect server
|
||||
- `tmux` available for stable authenticated sessions during Hermes terminal calls (desktop app flow only)
|
||||
|
||||
## When to Use
|
||||
|
||||
- Install or configure 1Password CLI
|
||||
- Sign in with `op signin`
|
||||
- Read secret references like `op://Vault/Item/field`
|
||||
- Inject secrets into config/templates using `op inject`
|
||||
- Run commands with secret env vars via `op run`
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
### Service Account (recommended for Hermes)
|
||||
|
||||
Set `OP_SERVICE_ACCOUNT_TOKEN` in `~/.hermes/.env` (the skill will prompt for this on first load).
|
||||
No desktop app needed. Supports `op read`, `op inject`, `op run`.
|
||||
|
||||
```bash
|
||||
export OP_SERVICE_ACCOUNT_TOKEN="your-token-here"
|
||||
op whoami # verify — should show Type: SERVICE_ACCOUNT
|
||||
```
|
||||
|
||||
### Desktop App Integration (interactive)
|
||||
|
||||
1. Enable in 1Password desktop app: Settings → Developer → Integrate with 1Password CLI
|
||||
2. Ensure app is unlocked
|
||||
3. Run `op signin` and approve the biometric prompt
|
||||
|
||||
### Connect Server (self-hosted)
|
||||
|
||||
```bash
|
||||
export OP_CONNECT_HOST="http://localhost:8080"
|
||||
export OP_CONNECT_TOKEN="your-connect-token"
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install CLI:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install 1password-cli
|
||||
|
||||
# Linux (official package/install docs)
|
||||
# See references/get-started.md for distro-specific links.
|
||||
|
||||
# Windows (winget)
|
||||
winget install AgileBits.1Password.CLI
|
||||
```
|
||||
|
||||
2. Verify:
|
||||
|
||||
```bash
|
||||
op --version
|
||||
```
|
||||
|
||||
3. Choose an auth method above and configure it.
|
||||
|
||||
## Hermes Execution Pattern (desktop app flow)
|
||||
|
||||
Hermes terminal commands are non-interactive by default and can lose auth context between calls.
|
||||
For reliable `op` use with desktop app integration, run sign-in and secret operations inside a dedicated tmux session.
|
||||
|
||||
Note: This is NOT needed when using `OP_SERVICE_ACCOUNT_TOKEN` — the token persists across terminal calls automatically.
|
||||
|
||||
```bash
|
||||
SOCKET_DIR="${TMPDIR:-/tmp}/hermes-tmux-sockets"
|
||||
mkdir -p "$SOCKET_DIR"
|
||||
SOCKET="$SOCKET_DIR/hermes-op.sock"
|
||||
SESSION="op-auth-$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
|
||||
|
||||
# Sign in (approve in desktop app when prompted)
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "eval \"\$(op signin --account my.1password.com)\"" Enter
|
||||
|
||||
# Verify auth
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op whoami" Enter
|
||||
|
||||
# Example read
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op read 'op://Private/Npmjs/one-time password?attribute=otp'" Enter
|
||||
|
||||
# Capture output when needed
|
||||
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
|
||||
|
||||
# Cleanup
|
||||
tmux -S "$SOCKET" kill-session -t "$SESSION"
|
||||
```
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Read a secret
|
||||
|
||||
```bash
|
||||
op read "op://app-prod/db/password"
|
||||
```
|
||||
|
||||
### Get OTP
|
||||
|
||||
```bash
|
||||
op read "op://app-prod/npm/one-time password?attribute=otp"
|
||||
```
|
||||
|
||||
### Inject into template
|
||||
|
||||
```bash
|
||||
echo "db_password: {{ op://app-prod/db/password }}" | op inject
|
||||
```
|
||||
|
||||
### Run a command with secret env var
|
||||
|
||||
```bash
|
||||
export DB_PASSWORD="op://app-prod/db/password"
|
||||
op run -- sh -c '[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD is set" || echo "DB_PASSWORD missing"'
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never print raw secrets back to user unless they explicitly request the value.
|
||||
- Prefer `op run` / `op inject` instead of writing secrets into files.
|
||||
- If command fails with "account is not signed in", run `op signin` again in the same tmux session.
|
||||
- If desktop app integration is unavailable (headless/CI), use service account token flow.
|
||||
|
||||
## CI / Headless note
|
||||
|
||||
For non-interactive use, authenticate with `OP_SERVICE_ACCOUNT_TOKEN` and avoid interactive `op signin`.
|
||||
Service accounts require CLI v2.18.0+.
|
||||
|
||||
## References
|
||||
|
||||
- `references/get-started.md`
|
||||
- `references/cli-examples.md`
|
||||
- https://developer.1password.com/docs/cli/
|
||||
- https://developer.1password.com/docs/service-accounts/
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# op CLI examples
|
||||
|
||||
## Sign-in and identity
|
||||
|
||||
```bash
|
||||
op signin
|
||||
op signin --account my.1password.com
|
||||
op whoami
|
||||
op account list
|
||||
```
|
||||
|
||||
## Read secrets
|
||||
|
||||
```bash
|
||||
op read "op://app-prod/db/password"
|
||||
op read "op://app-prod/npm/one-time password?attribute=otp"
|
||||
```
|
||||
|
||||
## Inject secrets
|
||||
|
||||
```bash
|
||||
echo "api_key: {{ op://app-prod/openai/api key }}" | op inject
|
||||
op inject -i config.tpl.yml -o config.yml
|
||||
```
|
||||
|
||||
## Run command with secrets
|
||||
|
||||
```bash
|
||||
export DB_PASSWORD="op://app-prod/db/password"
|
||||
op run -- sh -c '[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD is set"'
|
||||
```
|
||||
21
optional-skills/security/1password/references/get-started.md
Normal file
21
optional-skills/security/1password/references/get-started.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# 1Password CLI get-started (summary)
|
||||
|
||||
Official docs: https://developer.1password.com/docs/cli/get-started/
|
||||
|
||||
## Core flow
|
||||
|
||||
1. Install `op` CLI.
|
||||
2. Enable desktop app integration in 1Password app.
|
||||
3. Unlock app.
|
||||
4. Run `op signin` and approve prompt.
|
||||
5. Verify with `op whoami`.
|
||||
|
||||
## Multiple accounts
|
||||
|
||||
- Use `op signin --account <subdomain.1password.com>`
|
||||
- Or set `OP_ACCOUNT`
|
||||
|
||||
## Non-interactive / automation
|
||||
|
||||
- Use service accounts and `OP_SERVICE_ACCOUNT_TOKEN`
|
||||
- Prefer `op run` and `op inject` for runtime secret handling
|
||||
3
optional-skills/security/DESCRIPTION.md
Normal file
3
optional-skills/security/DESCRIPTION.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Security
|
||||
|
||||
Skills for secrets management, credential handling, and security tooling integrations.
|
||||
218
skills/autonomous-ai-agents/opencode/SKILL.md
Normal file
218
skills/autonomous-ai-agents/opencode/SKILL.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
---
|
||||
name: opencode
|
||||
description: Delegate coding tasks to OpenCode CLI agent for feature implementation, refactoring, PR review, and long-running autonomous sessions. Requires the opencode CLI installed and authenticated.
|
||||
version: 1.2.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Coding-Agent, OpenCode, Autonomous, Refactoring, Code-Review]
|
||||
related_skills: [claude-code, codex, hermes-agent]
|
||||
---
|
||||
|
||||
# OpenCode CLI
|
||||
|
||||
Use [OpenCode](https://opencode.ai) as an autonomous coding worker orchestrated by Hermes terminal/process tools. OpenCode is a provider-agnostic, open-source AI coding agent with a TUI and CLI.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User explicitly asks to use OpenCode
|
||||
- You want an external coding agent to implement/refactor/review code
|
||||
- You need long-running coding sessions with progress checks
|
||||
- You want parallel task execution in isolated workdirs/worktrees
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OpenCode installed: `npm i -g opencode-ai@latest` or `brew install anomalyco/tap/opencode`
|
||||
- Auth configured: `opencode auth login` or set provider env vars (OPENROUTER_API_KEY, etc.)
|
||||
- Verify: `opencode auth list` should show at least one provider
|
||||
- Git repository for code tasks (recommended)
|
||||
- `pty=true` for interactive TUI sessions
|
||||
|
||||
## Binary Resolution (Important)
|
||||
|
||||
Shell environments may resolve different OpenCode binaries. If behavior differs between your terminal and Hermes, check:
|
||||
|
||||
```
|
||||
terminal(command="which -a opencode")
|
||||
terminal(command="opencode --version")
|
||||
```
|
||||
|
||||
If needed, pin an explicit binary path:
|
||||
|
||||
```
|
||||
terminal(command="$HOME/.opencode/bin/opencode run '...'", workdir="~/project", pty=true)
|
||||
```
|
||||
|
||||
## One-Shot Tasks
|
||||
|
||||
Use `opencode run` for bounded, non-interactive tasks:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Add retry logic to API calls and update tests'", workdir="~/project")
|
||||
```
|
||||
|
||||
Attach context files with `-f`:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Review this config for security issues' -f config.yaml -f .env.example", workdir="~/project")
|
||||
```
|
||||
|
||||
Show model thinking with `--thinking`:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Debug why tests fail in CI' --thinking", workdir="~/project")
|
||||
```
|
||||
|
||||
Force a specific model:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Refactor auth module' --model openrouter/anthropic/claude-sonnet-4", workdir="~/project")
|
||||
```
|
||||
|
||||
## Interactive Sessions (Background)
|
||||
|
||||
For iterative work requiring multiple exchanges, start the TUI in background:
|
||||
|
||||
```
|
||||
terminal(command="opencode", workdir="~/project", background=true, pty=true)
|
||||
# Returns session_id
|
||||
|
||||
# Send a prompt
|
||||
process(action="submit", session_id="<id>", data="Implement OAuth refresh flow and add tests")
|
||||
|
||||
# Monitor progress
|
||||
process(action="poll", session_id="<id>")
|
||||
process(action="log", session_id="<id>")
|
||||
|
||||
# Send follow-up input
|
||||
process(action="submit", session_id="<id>", data="Now add error handling for token expiry")
|
||||
|
||||
# Exit cleanly — Ctrl+C
|
||||
process(action="write", session_id="<id>", data="\x03")
|
||||
# Or just kill the process
|
||||
process(action="kill", session_id="<id>")
|
||||
```
|
||||
|
||||
**Important:** Do NOT use `/exit` — it is not a valid OpenCode command and will open an agent selector dialog instead. Use Ctrl+C (`\x03`) or `process(action="kill")` to exit.
|
||||
|
||||
### TUI Keybindings
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Enter` | Submit message (press twice if needed) |
|
||||
| `Tab` | Switch between agents (build/plan) |
|
||||
| `Ctrl+P` | Open command palette |
|
||||
| `Ctrl+X L` | Switch session |
|
||||
| `Ctrl+X M` | Switch model |
|
||||
| `Ctrl+X N` | New session |
|
||||
| `Ctrl+X E` | Open editor |
|
||||
| `Ctrl+C` | Exit OpenCode |
|
||||
|
||||
### Resuming Sessions
|
||||
|
||||
After exiting, OpenCode prints a session ID. Resume with:
|
||||
|
||||
```
|
||||
terminal(command="opencode -c", workdir="~/project", background=true, pty=true) # Continue last session
|
||||
terminal(command="opencode -s ses_abc123", workdir="~/project", background=true, pty=true) # Specific session
|
||||
```
|
||||
|
||||
## Common Flags
|
||||
|
||||
| Flag | Use |
|
||||
|------|-----|
|
||||
| `run 'prompt'` | One-shot execution and exit |
|
||||
| `--continue` / `-c` | Continue the last OpenCode session |
|
||||
| `--session <id>` / `-s` | Continue a specific session |
|
||||
| `--agent <name>` | Choose OpenCode agent (build or plan) |
|
||||
| `--model provider/model` | Force specific model |
|
||||
| `--format json` | Machine-readable output/events |
|
||||
| `--file <path>` / `-f` | Attach file(s) to the message |
|
||||
| `--thinking` | Show model thinking blocks |
|
||||
| `--variant <level>` | Reasoning effort (high, max, minimal) |
|
||||
| `--title <name>` | Name the session |
|
||||
| `--attach <url>` | Connect to a running opencode server |
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Verify tool readiness:
|
||||
- `terminal(command="opencode --version")`
|
||||
- `terminal(command="opencode auth list")`
|
||||
2. For bounded tasks, use `opencode run '...'` (no pty needed).
|
||||
3. For iterative tasks, start `opencode` with `background=true, pty=true`.
|
||||
4. Monitor long tasks with `process(action="poll"|"log")`.
|
||||
5. If OpenCode asks for input, respond via `process(action="submit", ...)`.
|
||||
6. Exit with `process(action="write", data="\x03")` or `process(action="kill")`.
|
||||
7. Summarize file changes, test results, and next steps back to user.
|
||||
|
||||
## PR Review Workflow
|
||||
|
||||
OpenCode has a built-in PR command:
|
||||
|
||||
```
|
||||
terminal(command="opencode pr 42", workdir="~/project", pty=true)
|
||||
```
|
||||
|
||||
Or review in a temporary clone for isolation:
|
||||
|
||||
```
|
||||
terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && opencode run 'Review this PR vs main. Report bugs, security risks, test gaps, and style issues.' -f $(git diff origin/main --name-only | head -20 | tr '\n' ' ')", pty=true)
|
||||
```
|
||||
|
||||
## Parallel Work Pattern
|
||||
|
||||
Use separate workdirs/worktrees to avoid collisions:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Fix issue #101 and commit'", workdir="/tmp/issue-101", background=true, pty=true)
|
||||
terminal(command="opencode run 'Add parser regression tests and commit'", workdir="/tmp/issue-102", background=true, pty=true)
|
||||
process(action="list")
|
||||
```
|
||||
|
||||
## Session & Cost Management
|
||||
|
||||
List past sessions:
|
||||
|
||||
```
|
||||
terminal(command="opencode session list")
|
||||
```
|
||||
|
||||
Check token usage and costs:
|
||||
|
||||
```
|
||||
terminal(command="opencode stats")
|
||||
terminal(command="opencode stats --days 7 --models anthropic/claude-sonnet-4")
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Interactive `opencode` (TUI) sessions require `pty=true`. The `opencode run` command does NOT need pty.
|
||||
- `/exit` is NOT a valid command — it opens an agent selector. Use Ctrl+C to exit the TUI.
|
||||
- PATH mismatch can select the wrong OpenCode binary/model config.
|
||||
- If OpenCode appears stuck, inspect logs before killing:
|
||||
- `process(action="log", session_id="<id>")`
|
||||
- Avoid sharing one working directory across parallel OpenCode sessions.
|
||||
- Enter may need to be pressed twice to submit in the TUI (once to finalize text, once to send).
|
||||
|
||||
## Verification
|
||||
|
||||
Smoke test:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Respond with exactly: OPENCODE_SMOKE_OK'")
|
||||
```
|
||||
|
||||
Success criteria:
|
||||
- Output includes `OPENCODE_SMOKE_OK`
|
||||
- Command exits without provider/model errors
|
||||
- For code tasks: expected files changed and tests pass
|
||||
|
||||
## Rules
|
||||
|
||||
1. Prefer `opencode run` for one-shot automation — it's simpler and doesn't need pty.
|
||||
2. Use interactive background mode only when iteration is needed.
|
||||
3. Always scope OpenCode sessions to a single repo/workdir.
|
||||
4. For long tasks, provide progress updates from `process` logs.
|
||||
5. Report concrete outcomes (files changed, tests, remaining risks).
|
||||
6. Exit interactive sessions with Ctrl+C or kill, never `/exit`.
|
||||
|
|
@ -27,6 +27,9 @@ def _ensure_discord_mock():
|
|||
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.Embed = MagicMock
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
|
|
|
|||
9
tests/gateway/test_discord_media_metadata.py
Normal file
9
tests/gateway/test_discord_media_metadata.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import inspect
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
|
||||
|
||||
def test_discord_media_methods_accept_metadata_kwarg():
|
||||
for method_name in ("send_voice", "send_image_file", "send_image"):
|
||||
signature = inspect.signature(getattr(DiscordAdapter, method_name))
|
||||
assert "metadata" in signature.parameters, method_name
|
||||
434
tests/gateway/test_discord_slash_commands.py
Normal file
434
tests/gateway/test_discord_slash_commands.py
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
"""Tests for native Discord slash command fast-paths (thread creation & auto-thread)."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
def _ensure_discord_mock():
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
return
|
||||
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
|
||||
sys.modules.setdefault("discord", discord_mod)
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class FakeTree:
|
||||
def __init__(self):
|
||||
self.commands = {}
|
||||
|
||||
def command(self, *, name, description):
|
||||
def decorator(fn):
|
||||
self.commands[name] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
config = PlatformConfig(enabled=True, token="***")
|
||||
adapter = DiscordAdapter(config)
|
||||
adapter._client = SimpleNamespace(
|
||||
tree=FakeTree(),
|
||||
get_channel=lambda _id: None,
|
||||
fetch_channel=AsyncMock(),
|
||||
user=SimpleNamespace(id=99999, name="HermesBot"),
|
||||
)
|
||||
return adapter
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /thread slash command registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registers_native_thread_slash_command(adapter):
|
||||
adapter._handle_thread_create_slash = AsyncMock()
|
||||
adapter._register_slash_commands()
|
||||
|
||||
command = adapter._client.tree.commands["thread"]
|
||||
interaction = SimpleNamespace(
|
||||
response=SimpleNamespace(defer=AsyncMock()),
|
||||
)
|
||||
|
||||
await command(interaction, name="Planning", message="", auto_archive_duration=1440)
|
||||
|
||||
interaction.response.defer.assert_awaited_once_with(ephemeral=True)
|
||||
adapter._handle_thread_create_slash.assert_awaited_once_with(interaction, "Planning", "", 1440)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _handle_thread_create_slash — success, session dispatch, failure
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_reports_success(adapter):
|
||||
created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock())
|
||||
parent_channel = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread), send=AsyncMock())
|
||||
interaction_channel = SimpleNamespace(parent=parent_channel)
|
||||
interaction = SimpleNamespace(
|
||||
channel=interaction_channel,
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "Kickoff", 1440)
|
||||
|
||||
parent_channel.create_thread.assert_awaited_once_with(
|
||||
name="Planning",
|
||||
auto_archive_duration=1440,
|
||||
reason="Requested by Jezza via /thread",
|
||||
)
|
||||
created_thread.send.assert_awaited_once_with("Kickoff")
|
||||
# Thread link shown to user
|
||||
interaction.followup.send.assert_awaited()
|
||||
args, kwargs = interaction.followup.send.await_args
|
||||
assert "<#555>" in args[0]
|
||||
assert kwargs["ephemeral"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_dispatches_session_when_message_provided(adapter):
|
||||
"""When a message is given, _dispatch_thread_session should be called."""
|
||||
created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock())
|
||||
parent_channel = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread))
|
||||
interaction = SimpleNamespace(
|
||||
channel=SimpleNamespace(parent=parent_channel),
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
adapter._dispatch_thread_session = AsyncMock()
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "Hello Hermes", 1440)
|
||||
|
||||
adapter._dispatch_thread_session.assert_awaited_once_with(
|
||||
interaction, "555", "Planning", "Hello Hermes",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_no_dispatch_without_message(adapter):
|
||||
"""Without a message, no session dispatch should occur."""
|
||||
created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock())
|
||||
parent_channel = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread))
|
||||
interaction = SimpleNamespace(
|
||||
channel=SimpleNamespace(parent=parent_channel),
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
adapter._dispatch_thread_session = AsyncMock()
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "", 1440)
|
||||
|
||||
adapter._dispatch_thread_session.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_falls_back_to_seed_message(adapter):
|
||||
created_thread = SimpleNamespace(id=555, name="Planning")
|
||||
seed_message = SimpleNamespace(id=777, create_thread=AsyncMock(return_value=created_thread))
|
||||
channel = SimpleNamespace(
|
||||
create_thread=AsyncMock(side_effect=RuntimeError("direct failed")),
|
||||
send=AsyncMock(return_value=seed_message),
|
||||
)
|
||||
interaction = SimpleNamespace(
|
||||
channel=channel,
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "Kickoff", 1440)
|
||||
|
||||
channel.send.assert_awaited_once_with("Kickoff")
|
||||
seed_message.create_thread.assert_awaited_once_with(
|
||||
name="Planning",
|
||||
auto_archive_duration=1440,
|
||||
reason="Requested by Jezza via /thread",
|
||||
)
|
||||
interaction.followup.send.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_reports_failure(adapter):
|
||||
channel = SimpleNamespace(
|
||||
create_thread=AsyncMock(side_effect=RuntimeError("direct failed")),
|
||||
send=AsyncMock(side_effect=RuntimeError("nope")),
|
||||
)
|
||||
interaction = SimpleNamespace(
|
||||
channel=channel,
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "", 1440)
|
||||
|
||||
interaction.followup.send.assert_awaited_once()
|
||||
args, kwargs = interaction.followup.send.await_args
|
||||
assert "Failed to create thread:" in args[0]
|
||||
assert "nope" in args[0]
|
||||
assert kwargs["ephemeral"] is True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _dispatch_thread_session — builds correct event and routes it
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_thread_session_builds_thread_event(adapter):
|
||||
"""Dispatched event should have chat_type=thread and chat_id=thread_id."""
|
||||
interaction = SimpleNamespace(
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
)
|
||||
|
||||
captured_events = []
|
||||
|
||||
async def capture_handle(event):
|
||||
captured_events.append(event)
|
||||
|
||||
adapter.handle_message = capture_handle
|
||||
|
||||
await adapter._dispatch_thread_session(interaction, "555", "Planning", "Hello!")
|
||||
|
||||
assert len(captured_events) == 1
|
||||
event = captured_events[0]
|
||||
assert event.text == "Hello!"
|
||||
assert event.source.chat_id == "555"
|
||||
assert event.source.chat_type == "thread"
|
||||
assert event.source.thread_id == "555"
|
||||
assert "TestGuild" in event.source.chat_name
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-thread: _auto_create_thread
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_uses_message_content_as_name(adapter):
|
||||
thread = SimpleNamespace(id=999, name="Hello world")
|
||||
message = SimpleNamespace(
|
||||
content="Hello world, how are you?",
|
||||
create_thread=AsyncMock(return_value=thread),
|
||||
)
|
||||
|
||||
result = await adapter._auto_create_thread(message)
|
||||
|
||||
assert result is thread
|
||||
message.create_thread.assert_awaited_once()
|
||||
call_kwargs = message.create_thread.await_args[1]
|
||||
assert call_kwargs["name"] == "Hello world, how are you?"
|
||||
assert call_kwargs["auto_archive_duration"] == 1440
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_truncates_long_names(adapter):
|
||||
long_text = "a" * 200
|
||||
thread = SimpleNamespace(id=999, name="truncated")
|
||||
message = SimpleNamespace(
|
||||
content=long_text,
|
||||
create_thread=AsyncMock(return_value=thread),
|
||||
)
|
||||
|
||||
result = await adapter._auto_create_thread(message)
|
||||
|
||||
assert result is thread
|
||||
call_kwargs = message.create_thread.await_args[1]
|
||||
assert len(call_kwargs["name"]) <= 80
|
||||
assert call_kwargs["name"].endswith("...")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_returns_none_on_failure(adapter):
|
||||
message = SimpleNamespace(
|
||||
content="Hello",
|
||||
create_thread=AsyncMock(side_effect=RuntimeError("no perms")),
|
||||
)
|
||||
|
||||
result = await adapter._auto_create_thread(message)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-thread integration in _handle_message
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
import discord as _discord_mod # noqa: E402 — mock or real, used below
|
||||
|
||||
|
||||
class _FakeTextChannel:
|
||||
"""A channel that is NOT a discord.Thread or discord.DMChannel."""
|
||||
|
||||
def __init__(self, channel_id=100, name="general", guild_name="TestGuild"):
|
||||
self.id = channel_id
|
||||
self.name = name
|
||||
self.guild = SimpleNamespace(name=guild_name, id=1)
|
||||
self.topic = None
|
||||
|
||||
|
||||
class _FakeThreadChannel(_discord_mod.Thread):
|
||||
"""isinstance(ch, discord.Thread) → True."""
|
||||
|
||||
def __init__(self, channel_id=200, name="existing-thread", guild_name="TestGuild", parent_id=100):
|
||||
# Don't call super().__init__ — mock Thread is just an empty type
|
||||
self.id = channel_id
|
||||
self.name = name
|
||||
self.guild = SimpleNamespace(name=guild_name, id=1)
|
||||
self.topic = None
|
||||
self.parent = SimpleNamespace(id=parent_id, name="general", guild=SimpleNamespace(name=guild_name, id=1))
|
||||
|
||||
|
||||
def _fake_message(channel, *, content="Hello", author_id=42, display_name="Jezza"):
|
||||
return SimpleNamespace(
|
||||
author=SimpleNamespace(id=author_id, display_name=display_name, bot=False),
|
||||
content=content,
|
||||
channel=channel,
|
||||
attachments=[],
|
||||
mentions=[],
|
||||
reference=None,
|
||||
created_at=None,
|
||||
id=12345,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_creates_thread_and_redirects(adapter, monkeypatch):
|
||||
"""When DISCORD_AUTO_THREAD=true, a new thread is created and the event routes there."""
|
||||
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||
|
||||
thread = SimpleNamespace(id=999, name="Hello")
|
||||
adapter._auto_create_thread = AsyncMock(return_value=thread)
|
||||
|
||||
captured_events = []
|
||||
|
||||
async def capture_handle(event):
|
||||
captured_events.append(event)
|
||||
|
||||
adapter.handle_message = capture_handle
|
||||
|
||||
msg = _fake_message(_FakeTextChannel(), content="Hello world")
|
||||
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
adapter._auto_create_thread.assert_awaited_once_with(msg)
|
||||
assert len(captured_events) == 1
|
||||
event = captured_events[0]
|
||||
assert event.source.chat_id == "999" # redirected to thread
|
||||
assert event.source.chat_type == "thread"
|
||||
assert event.source.thread_id == "999"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_disabled_by_default(adapter, monkeypatch):
|
||||
"""Without DISCORD_AUTO_THREAD, messages stay in the channel."""
|
||||
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||
|
||||
adapter._auto_create_thread = AsyncMock()
|
||||
|
||||
captured_events = []
|
||||
|
||||
async def capture_handle(event):
|
||||
captured_events.append(event)
|
||||
|
||||
adapter.handle_message = capture_handle
|
||||
|
||||
msg = _fake_message(_FakeTextChannel())
|
||||
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
adapter._auto_create_thread.assert_not_awaited()
|
||||
assert len(captured_events) == 1
|
||||
assert captured_events[0].source.chat_id == "100" # stays in channel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_skips_threads_and_dms(adapter, monkeypatch):
|
||||
"""Auto-thread should not create threads inside existing threads."""
|
||||
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||
|
||||
adapter._auto_create_thread = AsyncMock()
|
||||
|
||||
captured_events = []
|
||||
|
||||
async def capture_handle(event):
|
||||
captured_events.append(event)
|
||||
|
||||
adapter.handle_message = capture_handle
|
||||
|
||||
msg = _fake_message(_FakeThreadChannel())
|
||||
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
adapter._auto_create_thread.assert_not_awaited() # should NOT auto-thread
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config bridge
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_discord_auto_thread_config_bridge(monkeypatch, tmp_path):
|
||||
"""discord.auto_thread in config.yaml should be bridged to DISCORD_AUTO_THREAD env var."""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
# Write a config.yaml the loader will find
|
||||
hermes_dir = tmp_path / ".hermes"
|
||||
hermes_dir.mkdir()
|
||||
config_path = hermes_dir / "config.yaml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"discord": {"auto_thread": True},
|
||||
}))
|
||||
|
||||
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
from gateway.config import load_gateway_config
|
||||
load_gateway_config()
|
||||
|
||||
import os
|
||||
assert os.getenv("DISCORD_AUTO_THREAD") == "true"
|
||||
|
|
@ -1,14 +1,21 @@
|
|||
"""Tests for hermes doctor helpers."""
|
||||
"""Tests for hermes_cli.doctor."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from argparse import Namespace
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.doctor as doctor
|
||||
from hermes_cli import doctor as doctor_mod
|
||||
from hermes_cli.doctor import _has_provider_env_config
|
||||
|
||||
|
||||
class TestProviderEnvDetection:
|
||||
def test_detects_openai_api_key(self):
|
||||
content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=sk-test-key\n"
|
||||
content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***"
|
||||
assert _has_provider_env_config(content)
|
||||
|
||||
def test_detects_custom_endpoint_without_openrouter_key(self):
|
||||
|
|
@ -47,7 +54,7 @@ class TestDoctorToolAvailabilityOverrides:
|
|||
|
||||
class TestHonchoDoctorConfigDetection:
|
||||
def test_reports_configured_when_enabled_with_api_key(self, monkeypatch):
|
||||
fake_config = SimpleNamespace(enabled=True, api_key="honcho-test-key")
|
||||
fake_config = SimpleNamespace(enabled=True, api_key="***")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"honcho_integration.client.HonchoClientConfig.from_global_config",
|
||||
|
|
@ -57,7 +64,7 @@ class TestHonchoDoctorConfigDetection:
|
|||
assert doctor._honcho_is_configured_for_doctor()
|
||||
|
||||
def test_reports_not_configured_without_api_key(self, monkeypatch):
|
||||
fake_config = SimpleNamespace(enabled=True, api_key=None)
|
||||
fake_config = SimpleNamespace(enabled=True, api_key="")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"honcho_integration.client.HonchoClientConfig.from_global_config",
|
||||
|
|
@ -65,3 +72,32 @@ class TestHonchoDoctorConfigDetection:
|
|||
)
|
||||
|
||||
assert not doctor._honcho_is_configured_for_doctor()
|
||||
|
||||
|
||||
def test_run_doctor_sets_interactive_env_for_tool_checks(monkeypatch, tmp_path):
|
||||
"""Doctor should present CLI-gated tools as available in CLI context."""
|
||||
project_root = tmp_path / "project"
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
project_root.mkdir()
|
||||
hermes_home.mkdir()
|
||||
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project_root)
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_check_tool_availability(*args, **kwargs):
|
||||
seen["interactive"] = os.getenv("HERMES_INTERACTIVE")
|
||||
raise SystemExit(0)
|
||||
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=fake_check_tool_availability,
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
|
||||
assert seen["interactive"] == "1"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue