mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
* fix(tests): close the staging test backlog — rust suite green, e2e 14→4 A pass over staging turned up 12 rust test failures and 14 playwright e2e failures + 1 fixture error. Most were wiring/invariant drift or stale test expectations around engine v2. This patch cleans up the ones with clear root causes. Rust (12 → 0): - `tools::builtin::skill_tools` (8 tests): ripped out hand-rolled ZIP byte blobs that were missing the EOCD record since the extractor switched to `zip::ZipArchive::new` in #2385. Tests now build through `zip::ZipWriter`, matching the production path. Drops the obsolete nested-path assertion whose assumption conflicts with intentional GitHub-archive root stripping. - `extensions::manager::test_telegram_token_colon_preserved_in_validation_url`: `src/pairing/approval.rs::propagate_approval_restores_runtime_state_when_on_start_fails` was mutating the `IRONCLAW_TEST_TELEGRAM_API_BASE_URL` runtime-env overlay without holding `ENV_MUTEX`. Now acquires `lock_env()` so concurrent readers see a stable value. - `bridge::router::handle_with_engine_persists_attachment_files_and_indexes_them`: two distinct `ENGINE_STATE_TEST_LOCK` statics (one in `test_support`, one in the sibling `tests` module) meant cross-module tests raced on the shared `ENGINE_STATE` `OnceLock`. Replaced the private duplicate with `use super::test_support::ENGINE_STATE_TEST_LOCK`. - `e2e_attachments::engine_v2_channel_attachments_persist_for_telegram_and_whatsapp`: attachment persistence resolves paths through the cached `bootstrap::ironclaw_base_dir()`, not the test's tempdir CWD. Added `bridge::override_engine_project_root_for_test` and wired the test to use it. - `telegram_auth_integration::test_group_message_emits_chat_type_metadata`: local fix — rebuild `channels-src/telegram` so the WASM picks up the April-17 `chat_type` emit from #2513. CI rebuilds the module per run, so no binary committed here. Playwright (14 failed + 1 error → 4 failed + 1 error): - `test_chat.py::test_gateway_attachment_flow_renders_thread_and_reaches_llm` and the unextractable variant: a legacy change listener on `#image-file-input` fired before the unified `handleAttachmentFiles` path, cleared `e.target.value`, and left the FileList empty by the time the unified handler ran. Removed the duplicate wiring in `crates/ironclaw_gateway/static/js/surfaces/chat.js`. - `test_chat.py::test_slash_autocomplete_shows_commands_and_skills`: `SLASH_COMMANDS` never merged installed skills. Added `refreshSlashSkillEntries()` that fetches `/api/skills` on menu open and re-runs the filter once the skills land. - `test_pending_user_messages.py::test_pending_message_survives_sse_reconnect`: the SSE open handler only reloads history when `disconnectMs > SSE_RELOAD_THRESHOLD_MS`; the test's instant reconnect skipped that. Ages `_sseDisconnectedAt` past threshold. - `test_pending_user_messages.py::test_welcome_card_hidden_when_pending`: `_create_new_thread` returned `currentThreadId` before the new-thread API round-trip set it, so callers got the pre-click id and keyed `_pendingUserMessages` on the wrong thread. Now waits for the id to change. - `TestV2EngineSkillInstallFlow` (7 → 2 failures): - Skill card template didn't render `usage_hint`, `has_requirements`, `has_scripts`, or `install_source_url`. Extended `renderSkillCard` in `surfaces/skills.js`. - The deny message `"Do not execute it; choose an alternative approach"` accidentally matched `user_signals_execution_intent`'s EXEC_PHRASES ("execute it"), re-arming `require_action_attempt` on resume and nudging the LLM into another tool call. Rephrased to `"Do not retry; choose a different approach"` in `src/bridge/router.rs`. Partial progress (still failing, needs deeper engine-v2 work): - `test_v2_engine_oauth_google::test_oauth_token_refresh_on_expiry`: added an `oauth:` block to the test's `google_drive` skill (which registers a refresh config via `credential_spec_to_oauth_refresh`) and aligned `GOOGLE_OAUTH_CLIENT_ID` with the mock proxy's expected `hosted-google-client-id`. Thread still hits the auth gate instead of refreshing — the pre-flight path isn't reaching `oauth_refresh_for_secret("google_drive_token")`; needs instrumentation on the engine-v2 gate pipeline. Net: rust suite green, playwright 4 failures left (2 skill-install approval-flow edge cases, 1 OAuth refresh, 1 REPL auth that flakes only under full-suite load) + 1 restart-fixture health-check timeout that flakes under 20-min suite pressure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): close the final 4 e2e failures + add copy-button coverage Follow-up to the earlier staging test pass. Drives the remaining playwright failures to green and adds the missing test for the per-message Copy button. New coverage: - `test_chat.py::test_message_copy_button_writes_raw_text`: clicking the per-message Copy button writes the raw text (user turn) or the raw markdown (assistant, via `data-raw`) to navigator.clipboard and flashes the button label to "Copied!" then back to "Copy". The existing `test_copy_from_chat_forces_plain_text` only covered the Cmd+C selection handler, so a regression to the button path was invisible. Fixes: - `TestV2EngineSkillInstallFlow::test_implicit_skill_activation_works_immediately_after_install`: the pika skill manifest uses the legacy `metadata.openclaw.requires` shape without a top-level `activation:` block, so `score_skill` scored 0 for every prompt and the skill never activated unless the user typed `/pikastream-video-meeting`. "Please use pikastream-video-meeting to prepare this call" should activate just like the slash form. `score_skill` now treats the skill name (and the hyphen/underscore-normalized form) as an implicit keyword, gated at ≥4 chars so short generic names don't false-match. `test_installed_skill_does_not_overfire_on_unrelated_prompt` still passes — a grocery-list prompt doesn't accidentally trigger pika. - `TestV2EngineSkillInstallFlow::test_duplicate_install_is_idempotent_and_keeps_single_card`: the test was waiting for an approval card on the second install, but `SkillInstallTool::requires_approval` short-circuits to `ApprovalRequirement::Never` when the skill is already loaded — asking the user to approve a guaranteed no-op is pure friction, and the test was asserting against that intentional behavior. Rewrote the test to skip the approval step and assert on the terminal message's idempotent "already installed / no install needed" wording, which matches the actual production output. - Mock LLM: the pattern branch in `match_tool_call` was re-emitting a matching tool call on every LLM round because "last user content" doesn't change across turns, so the engine looped until it hit the multi-result summary path. Added a guard that falls through to the text-response path when the matching tool_name is already present in `recent_tool_results` — mirroring real LLM behavior. - `test_v2_engine_oauth_google::test_oauth_token_refresh_on_expiry`: two compounding issues blocked the refresh path. (1) The mock `/oauth/refresh` handler validates `client_id == "hosted-google- client-id"`, but the fixture env set `test-google-client-id`. (2) Proxy URL points at `http://127.0.0.1:<port>` (the mock LLM) and the production SSRF guard blocks loopback by default; mock E2E tests opt in via `IRONCLAW_OAUTH_PROXY_ALLOW_LOOPBACK=1`. Also added an `oauth:` block to the test's `google_drive` skill so `credential_spec_to_oauth_refresh` registers a refresh config under `google_drive_token`. Finally, the refresh path needs a stored refresh token — paste-based auth (the earlier tests' fallback when no google-drive WASM binary is available) only persists the access token, so the test now skips in that configuration rather than asserting on a refresh that can't happen, matching the pattern already used by `test_oauth_redirect_flow`. Remaining after this PR: `test_repl_http_auth_prompt_accepts_token_and_retries` passes in isolation but flakes under full-suite load (the PTY REPL sibling test is already `@pytest.mark.skip` for the same reason); and `test_always_approve_survives_restart` which times out the `/api/health` probe under full-suite pressure. Both are PTY / fixture-startup concurrency issues, not product regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): close the last 2 e2e failures — full suite green (401 passed, 0 failed) Root-causes the two tests left open after the previous commit. Both were real bugs/config drift masquerading as flakiness. - `test_repl_http_auth_prompt_accepts_token_and_retries`: `CLI_MODE` defaults to `tui` (the ratatui full-screen UI), which reads stdin keystroke-by-keystroke and renders into a framebuffer. The PTY-driven tests in this file send whole lines via `os.write(master_fd, b"prompt\n")` and match for specific text in the raw stream — under the default TUI that line-based send never reaches the agent, so the auth card never fires and `_read_repl_until` times out with only cursor-position escape sequences captured. Pinning `CLI_MODE=repl` on the fixture routes the test back onto the plain line-based REPL surface it's written against. Confirmed passing 5/5 in isolation and under full-suite load. - `test_always_approve_survives_restart`: the fixture's ironclaw subprocess was dying at startup with `Channel webhook_server failed to start: Failed to bind to 127.0.0.1:8080: Address already in use (os error 98)` — the fixture picked a free `GATEWAY_PORT` but left `HTTP_HOST`/ `HTTP_PORT` unset, so the HTTP channel tried to claim the default port 8080 and collided with every other e2e server (and anything else on 8080). Every `/api/health` probe was hitting a dead process, which showed up as a 60 s timeout instead of a bind error because the subprocess's stderr was never drained — a full 64 KiB pipe buffer made the child block on its next write before it could even log the bind failure. Fix: - allocate a second free TCP port for `HTTP_PORT` (mirrors the sibling `v2_approval_server` fixture); - wire `stdout`/`stderr` through background drain tasks so `RUST_LOG=ironclaw=debug` output can't back-pressure the child into a startup hang; - surface the last 32 KiB of stderr in the timeout error so future regressions (panic, bind conflict) show up in the failure message instead of being silently swallowed. Full-suite e2e: 401 passed, 8 skipped, 0 failed, 0 errored (17:31). Rust unit + integration tests still green, clippy clean, fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): address PR #2744 review + reconcile with staging ## Review feedback - **Slash-skill cache spam (3× — Gemini + 2× Copilot):** the previous `refreshSlashSkillEntries()` re-fetched `/api/skills` on every keystroke in `filterSlashCommands`; the in-flight guard only suppressed concurrent duplicates. Added a 30 s TTL and an `invalidateSlashSkillCache()` hook that the install/remove flows in `surfaces/skills.js` call so the menu picks up install/remove changes immediately instead of waiting for the TTL. - **Wrong module path in comment (Copilot):** `src/bridge/router.rs` comment referenced `llm::reasoning::user_signals_execution_intent` but `reasoning` isn't `pub` — the helper is re-exported as `crate::llm::user_signals_execution_intent`. Updated the comment to use the canonical path and cross-reference the defining file. - **Misleading `#[tokio::test]` justification (Copilot):** prior comment said "single-threaded tokio and cannot deadlock" without pinning the runtime flavor. `#[tokio::test]` *does* default to the current-thread runtime in this crate, but spelling it out is safer against future defaults drifting. Pinned `#[tokio::test(flavor = "current_thread")]` explicitly and reworded the comment to name the runtime kind. - **Drain tasks cancelled but not awaited (Copilot):** the restart fixture in `test_v2_engine_approval_flow.py` cancelled the stdout/stderr drainers on `stop()` without awaiting them, causing "Task was destroyed but it is pending!" warnings and, on stop→start cycles, zombie readers. Now cancels *and* `asyncio.gather (..., return_exceptions=True)` awaits them. ## Merge reconciliation with `origin/staging` Staging merge introduced: - A strict MIME allowlist on `/api/chat/send` attachments (#2332). `test_gateway_attachment_unextractable_file_uses_placeholder` previously relied on `application/octet-stream` reaching `document_extraction` and triggering the "[Failed to extract …]" placeholder; the new gateway-side allowlist rejects that MIME outright at the HTTP layer, so the test never exercised the fallback path. Updated the test to upload a corrupt PDF (`%PDF-1.4` magic + garbage body) which passes MIME + header checks but fails extraction — the exact scenario the placeholder was designed for. - A conflict in `src/pairing/approval.rs` where staging added `#[ignore]` to the propagate-approval test (needs a pre-built telegram WASM binary) and this branch added `#[allow(clippy::await_holding_lock)]`. Merged both, plus pinned the explicit `current_thread` runtime flavor per review. ## Pre-existing failures left alone `test_portfolio.py::test_portfolio_chat_keyword_triggers_skill` and `test_portfolio_wallet_address_triggers_skill` both fail identically against plain `origin/staging` (verified via `git stash` + checkout of the staging versions of the test file and `crates/ironclaw_engine/orchestrator/default.py`). Root cause is unrelated to this PR — appears to be the mock LLM's portfolio response text tripping the engine's tool-intent nudge path before reaching the canned response the test asserts on. Out of scope here. ## Verification - `cargo fmt` - `cargo clippy --all --benches --tests --examples --all-features` — zero warnings - `cargo test --lib` — 5329 passed, 7 ignored, 0 failed - `pytest scenarios/test_chat.py scenarios/test_v2_engine_approval_flow.py scenarios/test_v2_engine_auth_flow.py::TestV2EngineSkillInstallFlow scenarios/test_v2_auth_oauth_matrix.py scenarios/test_pending_user_messages.py` — **58 passed, 1 skipped, 0 failed** Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fmt): collapse override_engine_project_root call onto single line rustfmt on staging collapses this call; my earlier `cargo fmt` ran before the `project_root.clone()` edit landed so the local check missed it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address PR #2744 review: startup-timeout leak + attachment test isolation 1. `test_v2_engine_approval_flow.py` — `start()` re-raised `TimeoutError` from `wait_for_ready` without tearing the subprocess down. Because `await start()` runs before the fixture's `try/finally`, a startup timeout would leak the child process and its bound ports into the rest of the test run. Snapshot the stderr tail before teardown, `await stop()` (which kills the proc and cancels/awaits the drain tasks), then re-raise with the captured tail. 2. `tests/e2e_attachments.rs` — the `engine_v2_project_root()` helper derived from `bootstrap::ironclaw_base_dir()` is a process-global `LazyLock` that resolves to `$HOME/.ironclaw` on dev machines and CI runners. Passing its parent as the engine's project_root meant this test was writing real attachment files into `~/.ironclaw/attachments` every time it ran. Allocate a per-test `tempfile::TempDir` instead and point `override_engine_project_root_for_test` at it — now writes are fully contained. The `engine_v2_attachment_root_lock` mutex stays (still required to serialize mutations of the process-global engine state across concurrent tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
317 lines
12 KiB
Rust
317 lines
12 KiB
Rust
//! E2E tests for attachment processing in the LLM pipeline.
|
|
//!
|
|
//! Verifies that attachments on incoming messages are augmented into the user
|
|
//! text and (for images) passed as multimodal content parts to the LLM.
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod support;
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod attachment_tests {
|
|
use std::sync::OnceLock;
|
|
use std::time::Duration;
|
|
|
|
use tokio::sync::Mutex;
|
|
|
|
use crate::support::test_rig::TestRigBuilder;
|
|
use crate::support::trace_llm::LlmTrace;
|
|
|
|
use ironclaw::channels::{AttachmentKind, IncomingAttachment, IncomingMessage};
|
|
use ironclaw::llm::ContentPart;
|
|
|
|
const FIXTURES: &str = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/spot"
|
|
);
|
|
const TIMEOUT: Duration = Duration::from_secs(15);
|
|
|
|
/// Serialize tests that mutate the process-global engine `project_root`
|
|
/// via `override_engine_project_root_for_test`. Concurrent overrides
|
|
/// would trample each other's paths — the lock is still required even
|
|
/// though each test now uses its own tempdir.
|
|
fn engine_v2_attachment_root_lock() -> &'static Mutex<()> {
|
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
LOCK.get_or_init(|| Mutex::new(()))
|
|
}
|
|
|
|
fn make_attachment(kind: AttachmentKind) -> IncomingAttachment {
|
|
IncomingAttachment {
|
|
id: "att-1".to_string(),
|
|
kind,
|
|
mime_type: "application/octet-stream".to_string(),
|
|
filename: None,
|
|
size_bytes: None,
|
|
source_url: None,
|
|
storage_key: None,
|
|
local_path: None,
|
|
extracted_text: None,
|
|
data: vec![],
|
|
duration_secs: None,
|
|
}
|
|
}
|
|
|
|
/// Audio attachment with transcript reaches the LLM as augmented text.
|
|
#[tokio::test]
|
|
async fn attachment_audio_transcript_reaches_llm() {
|
|
let trace =
|
|
LlmTrace::from_file(format!("{FIXTURES}/attachment_audio_transcript.json")).unwrap();
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.build()
|
|
.await;
|
|
|
|
// Build a message with an audio attachment containing a transcript
|
|
let mut att = make_attachment(AttachmentKind::Audio);
|
|
att.filename = Some("voice.ogg".to_string());
|
|
att.mime_type = "audio/ogg".to_string();
|
|
att.extracted_text = Some("Hello, can you help me with my project?".to_string());
|
|
att.duration_secs = Some(5);
|
|
|
|
let mut msg = IncomingMessage::new("test", "test-user", "Check this voice note");
|
|
msg.attachments.push(att);
|
|
|
|
rig.send_incoming(msg).await;
|
|
let responses = rig.wait_for_responses(1, TIMEOUT).await;
|
|
|
|
// Verify the response was received
|
|
assert!(
|
|
!responses.is_empty(),
|
|
"should receive at least one response"
|
|
);
|
|
|
|
// Verify the augmented content reached the LLM
|
|
let requests = rig.captured_llm_requests();
|
|
assert!(!requests.is_empty(), "LLM should have been called");
|
|
|
|
let last_request = &requests[requests.len() - 1];
|
|
let last_user_msg = last_request
|
|
.iter()
|
|
.rev()
|
|
.find(|m| matches!(m.role, ironclaw::llm::Role::User))
|
|
.expect("should have a user message");
|
|
|
|
// The augmented text should contain the attachment tags and transcript
|
|
assert!(
|
|
last_user_msg.content.contains("<attachments>"),
|
|
"user message should contain <attachments> block, got: {}",
|
|
last_user_msg.content.chars().take(200).collect::<String>()
|
|
);
|
|
assert!(
|
|
last_user_msg
|
|
.content
|
|
.contains("Hello, can you help me with my project?"),
|
|
"user message should contain the transcript"
|
|
);
|
|
assert!(
|
|
last_user_msg.content.contains("duration=\"5s\""),
|
|
"user message should contain duration"
|
|
);
|
|
|
|
// Audio attachments should NOT produce image content parts
|
|
assert!(
|
|
last_user_msg.content_parts.is_empty(),
|
|
"audio attachments should not produce image content parts"
|
|
);
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
rig.shutdown();
|
|
}
|
|
|
|
/// Image attachment with data reaches the LLM with multimodal content parts.
|
|
#[tokio::test]
|
|
async fn attachment_image_produces_content_parts() {
|
|
let trace = LlmTrace::from_file(format!("{FIXTURES}/attachment_image.json")).unwrap();
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.build()
|
|
.await;
|
|
|
|
// Build a message with an image attachment that has raw data
|
|
let mut att = make_attachment(AttachmentKind::Image);
|
|
att.filename = Some("screenshot.png".to_string());
|
|
att.mime_type = "image/png".to_string();
|
|
att.size_bytes = Some(1024);
|
|
att.data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG magic bytes (fake)
|
|
|
|
let mut msg =
|
|
IncomingMessage::new("test", "test-user", "What do you see in this screenshot?");
|
|
msg.attachments.push(att);
|
|
|
|
rig.send_incoming(msg).await;
|
|
let responses = rig.wait_for_responses(1, TIMEOUT).await;
|
|
|
|
assert!(
|
|
!responses.is_empty(),
|
|
"should receive at least one response"
|
|
);
|
|
|
|
// Verify multimodal content parts reached the LLM
|
|
let requests = rig.captured_llm_requests();
|
|
assert!(!requests.is_empty(), "LLM should have been called");
|
|
|
|
let last_request = &requests[requests.len() - 1];
|
|
let last_user_msg = last_request
|
|
.iter()
|
|
.rev()
|
|
.find(|m| matches!(m.role, ironclaw::llm::Role::User))
|
|
.expect("should have a user message");
|
|
|
|
// Should have image content parts
|
|
assert_eq!(
|
|
last_user_msg.content_parts.len(),
|
|
1,
|
|
"should have exactly one image content part"
|
|
);
|
|
|
|
// Verify the content part is an ImageUrl with a data: URI
|
|
match &last_user_msg.content_parts[0] {
|
|
ContentPart::ImageUrl { image_url } => {
|
|
assert!(
|
|
image_url.url.starts_with("data:image/png;base64,"),
|
|
"image URL should be a base64 data URI, got: {}",
|
|
&image_url.url[..image_url.url.len().min(40)]
|
|
);
|
|
}
|
|
other => panic!("expected ImageUrl content part, got: {:?}", other),
|
|
}
|
|
|
|
// The text should note the image is sent as visual content
|
|
assert!(
|
|
last_user_msg.content.contains(
|
|
"[Image attached — you can already see this image directly in the conversation."
|
|
),
|
|
"augmented text should note image sent as visual content"
|
|
);
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
rig.shutdown();
|
|
}
|
|
|
|
/// Message without attachments should have no content_parts and no augmentation.
|
|
#[tokio::test]
|
|
async fn no_attachments_no_augmentation() {
|
|
let trace = LlmTrace::from_file(format!("{FIXTURES}/smoke_greeting.json")).unwrap();
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Hello! Introduce yourself briefly.").await;
|
|
let responses = rig.wait_for_responses(1, TIMEOUT).await;
|
|
|
|
let requests = rig.captured_llm_requests();
|
|
let last_request = &requests[requests.len() - 1];
|
|
let last_user_msg = last_request
|
|
.iter()
|
|
.rev()
|
|
.find(|m| matches!(m.role, ironclaw::llm::Role::User))
|
|
.expect("should have a user message");
|
|
|
|
// No attachments → no augmentation tags, no content parts
|
|
assert!(
|
|
!last_user_msg.content.contains("<attachments>"),
|
|
"plain message should NOT contain <attachments>"
|
|
);
|
|
assert!(
|
|
last_user_msg.content_parts.is_empty(),
|
|
"plain message should have no content parts"
|
|
);
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
rig.shutdown();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn engine_v2_channel_attachments_persist_for_telegram_and_whatsapp() {
|
|
let _guard = engine_v2_attachment_root_lock().lock().await;
|
|
|
|
// Each iteration gets its own tempdir so attachment writes stay
|
|
// fully off the host HOME directory. The previous version of this
|
|
// test derived `project_root` from `bootstrap::ironclaw_base_dir()`
|
|
// and would actually write real files into `~/.ironclaw/attachments`
|
|
// on a dev machine / CI runner.
|
|
let project_root_tmp = tempfile::tempdir().expect("create attachment project_root tempdir");
|
|
let project_root = project_root_tmp.path().to_path_buf();
|
|
|
|
for channel in ["telegram", "whatsapp"] {
|
|
let rig = TestRigBuilder::new().with_engine_v2().build().await;
|
|
// Redirect the engine's project_root to our tempdir so the
|
|
// assertion on saved_path below sees the real write. Without
|
|
// this override the engine joins paths against the cached
|
|
// `ironclaw_base_dir()` (first-caller-wins LazyLock), which
|
|
// resolves to `$HOME/.ironclaw` and is unrelated to the
|
|
// per-test tempdir here.
|
|
assert!(
|
|
ironclaw::bridge::override_engine_project_root_for_test(project_root.clone()).await,
|
|
"engine state should be installed after build()"
|
|
);
|
|
|
|
let attachment_bytes = format!("Attachment from {channel}").into_bytes();
|
|
let mut msg = IncomingMessage::new(channel, "cross-channel-user", "check this file");
|
|
msg.attachments.push(IncomingAttachment {
|
|
id: format!("{channel}-att-1"),
|
|
kind: AttachmentKind::Document,
|
|
mime_type: "text/plain".to_string(),
|
|
filename: Some(format!("{channel}-notes.txt")),
|
|
size_bytes: Some(attachment_bytes.len() as u64),
|
|
source_url: None,
|
|
storage_key: None,
|
|
local_path: None,
|
|
extracted_text: Some(format!("Attachment from {channel}")),
|
|
data: attachment_bytes.clone(),
|
|
duration_secs: None,
|
|
});
|
|
|
|
rig.send_incoming(msg).await;
|
|
let deadline = tokio::time::Instant::now() + TIMEOUT;
|
|
let requests = loop {
|
|
let requests = rig.captured_llm_requests();
|
|
if !requests.is_empty() {
|
|
break requests;
|
|
}
|
|
assert!(
|
|
tokio::time::Instant::now() < deadline,
|
|
"should capture an LLM request for {channel}"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
};
|
|
let last_request = requests.last().expect("captured LLM request");
|
|
let last_user_msg = last_request
|
|
.iter()
|
|
.rev()
|
|
.find(|m| matches!(m.role, ironclaw::llm::Role::User))
|
|
.expect("user message");
|
|
|
|
let expected_suffix = format!("{channel}-notes.txt");
|
|
let project_path = last_user_msg
|
|
.content
|
|
.split("project_path=\"")
|
|
.nth(1)
|
|
.and_then(|rest| rest.split('"').next())
|
|
.expect("project_path attribute");
|
|
assert!(
|
|
project_path.contains(".ironclaw/attachments/"),
|
|
"missing persisted attachment path for {channel}: {}",
|
|
last_user_msg.content
|
|
);
|
|
assert!(
|
|
project_path.ends_with(&expected_suffix),
|
|
"unexpected persisted path for {channel}: {project_path}"
|
|
);
|
|
|
|
let saved_path = project_root.join(project_path);
|
|
assert!(
|
|
saved_path.exists(),
|
|
"saved attachment missing: {}",
|
|
saved_path.display()
|
|
);
|
|
assert_eq!(
|
|
std::fs::read(saved_path).expect("read saved attachment"),
|
|
attachment_bytes
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
}
|
|
}
|