Files
optimclaw/src/worker/container.rs
T
88f4894a18 merge: resolve conflicts for PR #800 and #822 into staging (#881)
* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* refactor: unify three agentic loops into single AgenticLoop engine (#654)

Replace three independent copy-pasted agentic loops (dispatcher, worker,
container runtime) with a single shared engine in `agentic_loop.rs` that
all consumers customize via the `LoopDelegate` trait.

Phase 1 — Shared engine (`src/agent/agentic_loop.rs`, 205 lines):
  - `run_agentic_loop()` owns the core LLM → tool exec → repeat cycle
  - `LoopDelegate` trait (Send + Sync, &dyn dispatch) with 6 hook points
  - Tool intent nudge logic consolidated (was duplicated in 3 files)
  - Iteration limit + force-text behavior preserved

Phase 2 — Three delegate implementations:
  - `ChatDelegate` (dispatcher.rs): 3-phase approval flow, hooks, cost
    guard, context compaction, skill attenuation, interruption
  - `JobDelegate` (worker/job.rs): planning pre-loop phase, parallel
    JoinSet exec, mark_completed/stuck/failed, SSE streaming, self-repair
  - `ContainerDelegate` (worker/container.rs): sequential tool exec,
    HTTP-proxied LLM, container-safe tools, credential injection

Phase 3 — File moves and cleanup:
  - Delete `src/agent/worker.rs` — job logic moved to `src/worker/job.rs`
  - Rename `src/worker/runtime.rs` → `src/worker/container.rs`
  - Re-export `Worker`/`WorkerDeps` from `crate::worker` in `agent/mod.rs`
  - Update `scheduler.rs` imports to new worker location

Shared helpers (`src/tools/execute.rs`):
  - `execute_tool_with_safety()` replaces 4 copies of validate → timeout
    → execute → serialize
  - `process_tool_result()` replaces 3 copies of sanitize → wrap →
    ChatMessage (also used by thread_ops.rs approval resume paths)

Net result: -2,408 lines, zero duplicated loop logic, single code path
for tool intent nudge and completion detection.

Closes #654

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address review feedback from Copilot

1. scheduler.rs: Replace `unwrap_or` fallback with proper error
   propagation when parsing tool output JSON — surfaces bugs instead
   of silently changing the output type.

2. worker/job.rs: Drop MutexGuard before the cancellation `.await` in
   `check_signals()` to avoid holding a lock across an async I/O call
   (prevents `await_holding_lock` lint).

3. worker/job.rs: Restore consecutive rate-limit counter
   (MAX_CONSECUTIVE_RATE_LIMITS = 10) so sustained rate limiting marks
   the job stuck with "Persistent rate limiting" instead of silently
   burning through max_iterations.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: incorporate staging changes — token budget tracking + mark_failed

Merge staging's changes into the refactored JobDelegate:
- Add token budget tracking in call_llm (update_context/add_tokens)
- mark_stuck → mark_failed for iteration cap and rate-limit exhaustion
  (aligns with staging's #788 fix)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address zmanian's PR review — eliminate type erasure, clean up

Address all 6 review points from zmanian on PR #800:

1. Replace LoopOutcome::Custom(Box<dyn Any>) with typed
   LoopOutcome::NeedApproval(Box<PendingApproval>) — eliminates
   type erasure and downcast, resolves clippy large_enum_variant.

2. Remove dead max_tool_iterations field from ChatDelegate struct.

3. Add on_tool_intent_nudge() hook to LoopDelegate trait with
   implementations in Job and Container delegates for observability.

4. Fix SSE events in job worker to emit raw sanitized content
   instead of XML-wrapped <tool_output> tags.

5. Remove 4 duplicate completion tests from job.rs that were
   already covered by the shared util module.

6. Avoid logging full tool results — use result_size_bytes in
   debug logs (execute.rs, job.rs).

Also updates path references in CLAUDE.md, COVERAGE_PLAN.md,
and add-sse-event.md command.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(doctor): expand diagnostics from 7 to 16 health checks

* test: add unit tests for agentic_loop and execute shared modules

Add 16 tests covering the two new critical shared modules:

agentic_loop.rs (10 tests):
- Text response exits loop immediately
- Tool call → text response continuation
- LoopSignal::Stop exits before LLM call
- LoopSignal::InjectMessage adds user message to context
- Max iterations terminates with LoopOutcome::MaxIterations
- Tool intent nudge fires twice then caps
- before_llm_call early exit bypasses LLM
- truncate_for_preview: short string, long string, multibyte safety

execute.rs (6 tests):
- execute_tool_with_safety success path
- Missing tool returns ToolError::NotFound
- Tool execution failure propagates
- Per-tool timeout enforcement (50ms)
- process_tool_result XML wrapping on success
- process_tool_result error formatting

All 2,777 unit tests pass, 0 clippy warnings.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address code review — 9 issues across agentic loop, job worker, container

CRITICAL fixes:
- Rate-limit exhaustion now returns Err(LlmError::RateLimited) instead of
  Ok(Text("")), stopping the loop immediately with no ghost iteration.
  Below-threshold retries still use Text("") with an explicit empty-string
  guard in handle_text_response to skip injection.
- check_signals drains the entire message channel before returning,
  prioritizing Stop over UserMessage. Previously returned early on first
  UserMessage, silently dropping any queued Stop or additional messages.
- check_signals now detects all non-progressing job states (Cancelled,
  Failed, Stuck, Completed, Submitted, Accepted) instead of only
  Cancelled and Failed.

HIGH fixes:
- Error path in process_tool_result_job applies truncate_for_preview to
  bound error strings in SSE/DB events (was unbounded).
- Document Send+Sync lifetime constraint on LoopDelegate trait.
- Test mock before_llm_call refactored from double-lock to single lock
  acquisition, eliminating deadlock risk on refactor.

MEDIUM fixes:
- CompletionReport includes actual iteration count via shared
  Arc<Mutex<u32>> tracker (was hardcoded 0).
- process_tool_result_job return type changed from Result<bool> to
  Result<()> — the bool was always false (dead API).
- Deduplicate truncate in container.rs; now uses truncate_for_preview
  from agentic_loop.

Verified: 0 clippy warnings, 2781 tests pass, cargo fmt clean.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
Co-authored-by: Umesh Kumar Singh <[email protected]>
Co-authored-by: reidliu41 <[email protected]>
2026-03-10 11:19:23 -07:00

540 lines
18 KiB
Rust

//! Worker runtime: the main execution loop inside a container.
//!
//! Reuses the existing `Reasoning` and `SafetyLayer` infrastructure but
//! connects to the orchestrator for LLM calls instead of calling APIs directly.
//! Streams real-time events (message, tool_use, tool_result, result) through
//! the orchestrator's job event pipeline for UI visibility.
//!
//! Uses the shared `AgenticLoop` engine via `ContainerDelegate`.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::agent::agentic_loop::{
AgenticLoopConfig, LoopDelegate, LoopOutcome, LoopSignal, TextAction, truncate_for_preview,
};
use crate::config::SafetyConfig;
use crate::context::JobContext;
use crate::error::WorkerError;
use crate::llm::{ChatMessage, LlmProvider, Reasoning, ReasoningContext};
use crate::safety::SafetyLayer;
use crate::tools::ToolRegistry;
use crate::tools::execute::{execute_tool_simple, process_tool_result};
use crate::worker::api::{CompletionReport, JobEventPayload, StatusUpdate, WorkerHttpClient};
use crate::worker::proxy_llm::ProxyLlmProvider;
/// Configuration for the worker runtime.
pub struct WorkerConfig {
pub job_id: Uuid,
pub orchestrator_url: String,
pub max_iterations: u32,
pub timeout: Duration,
}
impl Default for WorkerConfig {
fn default() -> Self {
Self {
job_id: Uuid::nil(),
orchestrator_url: String::new(),
max_iterations: 50,
timeout: Duration::from_secs(600),
}
}
}
/// The worker runtime runs inside a Docker container.
///
/// It connects to the orchestrator over HTTP, fetches its job description,
/// then runs a tool execution loop until the job is complete. Events are
/// streamed to the orchestrator so the UI can show real-time progress.
pub struct WorkerRuntime {
config: WorkerConfig,
client: Arc<WorkerHttpClient>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
tools: Arc<ToolRegistry>,
/// Credentials fetched from the orchestrator, injected into child processes
/// via `Command::envs()` rather than mutating the global process environment.
///
/// Wrapped in `Arc` to avoid deep-cloning the map on every tool invocation.
extra_env: Arc<HashMap<String, String>>,
}
impl WorkerRuntime {
/// Create a new worker runtime.
///
/// Reads `IRONCLAW_WORKER_TOKEN` from the environment for auth.
pub fn new(config: WorkerConfig) -> Result<Self, WorkerError> {
let client = Arc::new(WorkerHttpClient::from_env(
config.orchestrator_url.clone(),
config.job_id,
)?);
let llm: Arc<dyn LlmProvider> = Arc::new(ProxyLlmProvider::new(
Arc::clone(&client),
"proxied".to_string(),
));
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: true,
}));
let tools = Arc::new(ToolRegistry::new());
// Register only container-safe tools
tools.register_container_tools();
Ok(Self {
config,
client,
llm,
safety,
tools,
extra_env: Arc::new(HashMap::new()),
})
}
/// Run the worker until the job is complete or an error occurs.
pub async fn run(mut self) -> Result<(), WorkerError> {
tracing::info!("Worker starting for job {}", self.config.job_id);
// Fetch job description from orchestrator
let job = self.client.get_job().await?;
tracing::info!(
"Received job: {} - {}",
job.title,
truncate_for_preview(&job.description, 100)
);
// Fetch credentials and store them for injection into child processes
// via Command::envs() (avoids unsafe std::env::set_var in multi-threaded runtime).
let credentials = self.client.fetch_credentials().await?;
{
let mut env_map = HashMap::new();
for cred in &credentials {
env_map.insert(cred.env_var.clone(), cred.value.clone());
}
self.extra_env = Arc::new(env_map);
}
if !credentials.is_empty() {
tracing::info!(
"Fetched {} credential(s) for child process injection",
credentials.len()
);
}
// Report that we're starting
self.client
.report_status(&StatusUpdate {
state: "in_progress".to_string(),
message: Some("Worker started, beginning execution".to_string()),
iteration: 0,
})
.await?;
// Create reasoning engine
let reasoning = Reasoning::new(self.llm.clone());
// Build initial context
let mut reason_ctx = ReasoningContext::new().with_job(&job.description);
reason_ctx.messages.push(ChatMessage::system(format!(
r#"You are an autonomous agent running inside a Docker container.
Job: {}
Description: {}
You have tools for shell commands, file operations, and code editing.
Work independently to complete this job. Report when done."#,
job.title, job.description
)));
// Load tool definitions
reason_ctx.available_tools = self.tools.tool_definitions().await;
// Shared iteration tracker — read after the loop to report accurate counts.
let iteration_tracker = Arc::new(Mutex::new(0u32));
// Run with timeout using the shared agentic loop
let result = tokio::time::timeout(self.config.timeout, async {
let delegate = ContainerDelegate {
client: self.client.clone(),
safety: self.safety.clone(),
tools: self.tools.clone(),
extra_env: self.extra_env.clone(),
last_output: Mutex::new(String::new()),
iteration_tracker: iteration_tracker.clone(),
};
let config = AgenticLoopConfig {
max_iterations: self.config.max_iterations as usize,
enable_tool_intent_nudge: true,
max_tool_intent_nudges: 2,
};
crate::agent::agentic_loop::run_agentic_loop(
&delegate,
&reasoning,
&mut reason_ctx,
&config,
)
.await
})
.await;
let iterations = *iteration_tracker.lock().await;
match result {
Ok(Ok(LoopOutcome::Response(output))) => {
tracing::info!("Worker completed job {} successfully", self.config.job_id);
self.post_event(
"result",
serde_json::json!({
"success": true,
"message": truncate_for_preview(&output, 2000),
}),
)
.await;
self.client
.report_complete(&CompletionReport {
success: true,
message: Some(output),
iterations,
})
.await?;
}
Ok(Ok(LoopOutcome::MaxIterations)) => {
let msg = format!("max iterations ({}) exceeded", self.config.max_iterations);
tracing::warn!("Worker failed for job {}: {}", self.config.job_id, msg);
self.post_event(
"result",
serde_json::json!({
"success": false,
"message": format!("Execution failed: {}", msg),
}),
)
.await;
self.client
.report_complete(&CompletionReport {
success: false,
message: Some(format!("Execution failed: {}", msg)),
iterations,
})
.await?;
}
Ok(Ok(LoopOutcome::Stopped | LoopOutcome::NeedApproval(_))) => {
tracing::info!("Worker for job {} stopped", self.config.job_id);
self.client
.report_complete(&CompletionReport {
success: false,
message: Some("Execution stopped".to_string()),
iterations,
})
.await?;
}
Ok(Err(e)) => {
tracing::error!("Worker failed for job {}: {}", self.config.job_id, e);
self.post_event(
"result",
serde_json::json!({
"success": false,
"message": format!("Execution failed: {}", e),
}),
)
.await;
self.client
.report_complete(&CompletionReport {
success: false,
message: Some(format!("Execution failed: {}", e)),
iterations,
})
.await?;
}
Err(_) => {
tracing::warn!("Worker timed out for job {}", self.config.job_id);
self.post_event(
"result",
serde_json::json!({
"success": false,
"message": "Execution timed out",
}),
)
.await;
self.client
.report_complete(&CompletionReport {
success: false,
message: Some("Execution timed out".to_string()),
iterations,
})
.await?;
}
}
Ok(())
}
/// Post a job event to the orchestrator (fire-and-forget).
async fn post_event(&self, event_type: &str, data: serde_json::Value) {
self.client
.post_event(&JobEventPayload {
event_type: event_type.to_string(),
data,
})
.await;
}
}
/// Container delegate: implements `LoopDelegate` for the Docker container context.
///
/// Tools execute sequentially. Events are posted to the orchestrator via HTTP.
/// Completion is detected via `llm_signals_completion()`.
struct ContainerDelegate {
client: Arc<WorkerHttpClient>,
safety: Arc<SafetyLayer>,
tools: Arc<ToolRegistry>,
extra_env: Arc<HashMap<String, String>>,
/// Tracks the last successful tool output for the final response.
last_output: Mutex<String>,
/// Tracks the current iteration — shared with the outer `run` method so
/// `CompletionReport` can include accurate iteration counts.
iteration_tracker: Arc<Mutex<u32>>,
}
impl ContainerDelegate {
async fn post_event(&self, event_type: &str, data: serde_json::Value) {
self.client
.post_event(&JobEventPayload {
event_type: event_type.to_string(),
data,
})
.await;
}
/// Poll the orchestrator for a follow-up prompt. If one is available,
/// inject it as a user message into the reasoning context.
async fn poll_and_inject_prompt(&self, reason_ctx: &mut ReasoningContext) {
match self.client.poll_prompt().await {
Ok(Some(prompt)) => {
tracing::info!(
"Received follow-up prompt: {}",
truncate_for_preview(&prompt.content, 100)
);
self.post_event(
"message",
serde_json::json!({
"role": "user",
"content": truncate_for_preview(&prompt.content, 2000),
}),
)
.await;
reason_ctx.messages.push(ChatMessage::user(&prompt.content));
}
Ok(None) => {}
Err(e) => {
tracing::debug!("Failed to poll for prompt: {}", e);
}
}
}
}
#[async_trait]
impl LoopDelegate for ContainerDelegate {
async fn check_signals(&self) -> LoopSignal {
// Container runtime has no stop signals — the orchestrator manages lifecycle.
LoopSignal::Continue
}
async fn before_llm_call(
&self,
reason_ctx: &mut ReasoningContext,
iteration: usize,
) -> Option<LoopOutcome> {
let iteration = iteration as u32;
*self.iteration_tracker.lock().await = iteration;
// Report progress every 5 iterations
if iteration % 5 == 1 {
let _ = self
.client
.report_status(&StatusUpdate {
state: "in_progress".to_string(),
message: Some(format!("Iteration {}", iteration)),
iteration,
})
.await;
}
// Poll for follow-up prompts from the user
self.poll_and_inject_prompt(reason_ctx).await;
// Refresh tools (in case WASM tools were built)
reason_ctx.available_tools = self.tools.tool_definitions().await;
None
}
async fn call_llm(
&self,
reasoning: &Reasoning,
reason_ctx: &mut ReasoningContext,
_iteration: usize,
) -> Result<crate::llm::RespondOutput, crate::error::Error> {
// Container uses respond_with_tools (which may return either text or tool calls)
reasoning
.respond_with_tools(reason_ctx)
.await
.map_err(Into::into)
}
async fn handle_text_response(
&self,
text: &str,
reason_ctx: &mut ReasoningContext,
) -> TextAction {
self.post_event(
"message",
serde_json::json!({
"role": "assistant",
"content": truncate_for_preview(text, 2000),
}),
)
.await;
// Check for completion
if crate::util::llm_signals_completion(text) {
let last = self.last_output.lock().await;
let output = if last.is_empty() {
text.to_string()
} else {
last.clone()
};
return TextAction::Return(LoopOutcome::Response(output));
}
reason_ctx.messages.push(ChatMessage::assistant(text));
TextAction::Continue
}
async fn execute_tool_calls(
&self,
tool_calls: Vec<crate::llm::ToolCall>,
content: Option<String>,
reason_ctx: &mut ReasoningContext,
) -> Result<Option<LoopOutcome>, crate::error::Error> {
if let Some(ref text) = content {
self.post_event(
"message",
serde_json::json!({
"role": "assistant",
"content": truncate_for_preview(text, 2000),
}),
)
.await;
}
// Add assistant message with tool_calls (OpenAI protocol)
reason_ctx
.messages
.push(ChatMessage::assistant_with_tool_calls(
content,
tool_calls.clone(),
));
// Execute tools sequentially (container context — no parallel execution)
for tc in tool_calls {
self.post_event(
"tool_use",
serde_json::json!({
"tool_name": tc.name,
"input": truncate_for_preview(&tc.arguments.to_string(), 500),
}),
)
.await;
let job_ctx = JobContext {
extra_env: self.extra_env.clone(),
..Default::default()
};
let result =
execute_tool_simple(&self.tools, &self.safety, &tc.name, &tc.arguments, &job_ctx)
.await;
self.post_event(
"tool_result",
serde_json::json!({
"tool_name": tc.name,
"output": match &result {
Ok(output) => truncate_for_preview(output, 2000),
Err(e) => format!("Error: {}", truncate_for_preview(e, 500)),
},
"success": result.is_ok(),
}),
)
.await;
if let Ok(ref output) = result {
*self.last_output.lock().await = output.clone();
}
// Use shared result processing
let (_, message) = process_tool_result(&self.safety, &tc.name, &tc.id, &result);
reason_ctx.messages.push(message);
}
Ok(None)
}
async fn on_tool_intent_nudge(&self, text: &str, _reason_ctx: &mut ReasoningContext) {
self.post_event(
"message",
serde_json::json!({
"role": "assistant",
"content": truncate_for_preview(text, 2000),
"nudge": true,
}),
)
.await;
}
async fn after_iteration(&self, _iteration: usize) {
// Brief pause between iterations
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
#[cfg(test)]
mod tests {
use crate::agent::agentic_loop::truncate_for_preview;
#[test]
fn test_truncate_within_limit() {
assert_eq!(truncate_for_preview("hello", 10), "hello");
}
#[test]
fn test_truncate_at_limit() {
assert_eq!(truncate_for_preview("hello", 5), "hello");
}
#[test]
fn test_truncate_beyond_limit() {
let result = truncate_for_preview("hello world", 5);
assert_eq!(result, "hello...");
}
#[test]
fn test_truncate_multibyte_safe() {
// "é" is 2 bytes in UTF-8; slicing at byte 1 would panic without safety
let result = truncate_for_preview("é is fancy", 1);
// Should truncate to 0 chars (can't fit "é" in 1 byte)
assert_eq!(result, "...");
}
}