mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-02 23:56:24 +08:00
* feat(gateway): add attachment flows and slash-skill coverage * feat(v2): persist project attachments across channels * feat(skills): install GitHub skill bundles * feat(v2): cover live skill install and setup flow * test(e2e): stabilize gateway and auth coverage * test(e2e): stabilize post-merge warnings and browser flows * fix(review): address follow-up PR feedback * fix(review): address remaining attachment and skill install comments * Address remaining attachment review comments * fix(ci): allowlist ws.rs → server::inline_attachments_to_incoming ws.rs was already allowlisted for the attachment shim symbols (`images_to_attachments`, the rate limiter types, etc.) so the new unified entrypoint added by this branch (combining images and generic attachments before validation) follows the same pattern. The entry will be removed together with the rest of the ws.rs server:: block once the attachment helpers migrate into platform/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(e2e): attachment persistence path and Slack activate signature Two e2e-surfacing regressions after merging staging: 1. `persist_project_attachments` was writing to `<base_dir>/projects/.ironclaw/attachments/...` because PR #2385's reviewer-requested switch from `std::env::current_dir()` to an explicit `project_root` kept the `.ironclaw/` prefix baked into `PROJECT_ATTACHMENT_DIR` while rooting at `ironclaw_base_dir()/projects`. Point `resolve_project_root()` at the parent of the base dir so `<parent>/.ironclaw/attachments/<owner>/<project>/...` matches the prompt's `project_path` and the user's expectation when base dir is `~/.ironclaw`. Updates the corresponding assertion in test_v2_engine_auth_flow.py to resolve paths against the fixture's home tempdir instead of the repo root. 2. `activate_slack()` grew a required `http_url` arg during the skill-install branch work but the `active_slack` fixture in test_slack_e2e.py still passed the old three-arg shape. That tripped every Slack scenario at setup (TypeError). Thread `http_url` from `slack_e2e_server` through the fixture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(engine-v2): auth-prompt surfacing, bundle_path injection, attachment-only inputs - Orchestrator formatter now writes `Installed bundle path on disk:` into each skill block so the skill body sees the bundle location it needs to reference (e.g. running `pip install -r <bundle>/requirements.txt`). Previously the bundle_path metadata field was populated but never surfaced into the prompt, so skills that rely on filesystem paths silently no-op'd. - The router no longer rejects messages whose text body is empty when the payload carries attachments. Safety validation's empty-input guard is a v1 input-sanity check; a pure-attachment follow-up (image upload with no caption) is a legitimate submission in the v2 gateway contract and previously tripped "Input cannot be empty". - The engine auth-flow e2e tests now detect gate-paused state via `HistoryResponse.pending_gate` (and `resume_kind.Authentication`) rather than scanning the turn response text for "paste your token". Auth instructions live in the `onboarding_state` SSE event, not in the chat response (see `test_auth_no_duplicate_response.py`); the old string-matching assertion was checking the wrong surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(e2e): switch approval/auth-prompt probes to pending_gate Approval and auth prompts are surfaced through HistoryResponse.pending_gate and the onboarding_state/gate_required SSE events, not as text in turns[-1].response — the duplicate-response regression guard in test_auth_no_duplicate_response.py explicitly forbids them from appearing in the chat transcript. Update the helpers in test_v2_engine_approval_flow.py, test_v2_engine_auth_cancel.py, and test_v2_kernel_auth_preflight.py to poll pending_gate instead of scanning turn text for "requires approval" or "paste your token". Unblocks 5 approval, 1 auth-cancel, and 3 preflight tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(e2e): google-oauth _wait_for_auth_prompt / _wait_for_response use pending_gate Bring the Google Drive / skill-OAuth regression file in line with the rest of the v2 e2e helpers: poll `HistoryResponse.pending_gate` for auth/approval prompts, and accept a pending_gate as a valid terminal state for `_wait_for_response` (an auth-retry chain that hits another gate is still progress, not a hang). Unblocks the oauth-cancel, invalid-token-paste, and api-key-then-api-call scenarios; the lingering token-refresh scenario still exposes a real v2 auto-refresh regression (the engine prompts the user instead of issuing a refresh against the stored refresh_token). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(e2e): relax a few stale v2-surface assertions - `test_skill_oauth_flow::test_auth_required_sse_event` was pinned to the old `onboarding_state/auth_required` SSE payload. The v2 gate pipeline delivers credential gates as `gate_required` (resume_kind `Authentication`) or, when preflight falls through to approval first, `approval_needed`. Accept any of those three, and treat a `thinking` "Running <tool>" status as evidence the tool call fired when no standalone `tool_started` event is emitted. - `test_message_persistence` helpers asserted HTTP 200 on `/api/chat/send`, but the gateway now returns 202 ACCEPTED (fire-and-forget). Accept both. - `test_project_detail` flipped the wrong global (`engineV2`) instead of `engineV2Enabled`, leaving the `data-v2-only` Projects tab hidden so the click timed out. Set the real flag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(review): address attachment index note correctness Two Copilot review findings on the attachment persistence path: - `attachment_index_note` in `src/bridge/router.rs` used the raw user-supplied filename in the markdown `# Uploaded attachment:` header and in the memory-doc `title` field. A filename with newlines / backticks / control characters would corrupt the agent-visible transcript and break searchable titles. Route the filename through a new `sanitize_filename_for_display` that strips control chars, collapses newlines/tabs to spaces, swaps backticks for apostrophes, truncates at 256 chars, and falls back to `"attachment"` when the sanitized result is empty. - `persist_project_attachments` cleared `attachment.data` before calling `attachment_index_note`, so the `size_bytes.unwrap_or( data.len() as u64)` fallback reported `0` bytes whenever the channel hadn't pre-populated `size_bytes`. Swap the order — build the index note while the buffer is still populated, then drop the bytes. Also adjust `src/agent/attachments.rs::format_attachment` for the Image arm: when `data` has been cleared but `local_path` is set (the engine-v2 persist-then-clear flow), the "visual content not available in this conversation" message is misleading — the image is available, just on disk. Surface a dedicated prompt that tells the agent to reference the project file path instead of trying to load bytes from memory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(e2e): cancel_during_auth asserts pending_gate clears, not chat text The test polled \`turns[-1].response\` for "cancel" but the cancel flow never writes an assistant row to the chat-history DB: resolve_gate returns \`BridgeOutcome::Respond("Cancelled.")\` which broadcasts via SSE and calls \`stop_thread\` on the engine thread, neither of which goes through the DB persistence path that populates turn responses. Switch the test to verify the user-visible signal the gateway actually emits — \`history.pending_gate\` disappears after "cancel" resolves the gate. Matches the approach used in \`test_v2_engine_approval_flow.py\`'s deny-flow tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(e2e): pairing approve test tolerates ExtensionName boundary reject Staging's new `features/pairing/` slice (ironclaw#2599 stage 4b) validates the `{channel}` URL segment through `ExtensionName::new` at the handler boundary: a path-traversal / control-character / whitespace-containing segment (like `evil.Ignore all`) now returns 400 instead of silently routing to a pairing-store miss. The regression test used to assert the older 200+JSON shape. Relax it to accept either 200 (generic `Invalid or expired pairing code.`) or 400 (boundary validation); the real invariant the test exists to protect — the raw injection-shaped channel string must not echo back into the response — is still asserted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(review): preserve image bytes through LLM call + document drive mock pin Two review findings: - `src/bridge/router.rs::persist_project_attachments` was clearing `attachment.data` after writing the file to disk. The very next step in `handle_with_engine_inner` is `augment_with_attachments`, which only emits a multimodal `image_parts` entry when `att.data` is non-empty — so every engine-v2 image upload was silently dropped from the LLM request even though the file landed on disk. The `persisted_attachments` Vec is local to the dispatch and is dropped as soon as the engine call returns, so the "storage hygiene" comment the clear used to justify was a no-op. Stop clearing; let RAII free the bytes. Updates `src/agent/attachments.rs`'s Image-arm prompt to reflect the refined invariant (`data.is_empty()` now implies a downstream caller or channel stripped the buffer, not the normal persist path). - `tests/e2e/scenarios/test_v2_engine_oauth_google.py::_pin_mock_drive_api_url` posts to `/__mock/set_github_api_url`. The wire name is historical — the Drive suite reused the knob — but the fixture name made the intent hard to follow. Adds a docstring that calls out the shared `_github_api_url` in `mock_llm.py` and explains why the endpoint rename would cascade into every other test that uses it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(review): address remaining Copilot feedback on PR 2385 - audio attachments: include `mime` (and size) attribute in `<attachment>` XML for parity with image/document so the frontend can render MIME and size in attachment cards - /api/skills list/search: parallelize per-skill filesystem I/O (`read_install_metadata`, `try_exists`, `metadata`) via `futures::future::join_all` instead of awaiting serially — keeps the handler O(n) in wall time for large skill sets - history parseUserMessageContent: only strip the trailing `<attachments>…</attachments>` block when at least one `<attachment>` tag is parsed from inside it, otherwise leave the raw text intact so user messages that legitimately end with that markup are preserved - sync_v1_skill_to_store: look up existing shared skill doc via `list_skills_global()` instead of `list_shared_memory_docs(project_id)` so shared skills installed under one project are updated in place when re-synced from another project (prevents duplicate shared docs across per-user projects) and preserve the original `project_id` on in-place update Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
648 lines
21 KiB
Rust
648 lines
21 KiB
Rust
//! Integration test: v2 engine skill activation with full CodeAct execution.
|
|
//!
|
|
//! Exercises the complete path:
|
|
//! 1. GitHub skill selected based on thread goal keywords
|
|
//! 2. LLM returns Python code calling `await http(...)` to fetch issues
|
|
//! 3. Monty VM executes the code, dispatches `http` to mock EffectExecutor
|
|
//! 4. Mock returns canned GitHub JSON response
|
|
//! 5. `FINAL(result)` terminates the code step
|
|
//! 6. Thread completes with the canned data in the response
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use tokio::sync::RwLock;
|
|
|
|
use ironclaw_engine::types::capability::{EffectType, LeaseId};
|
|
use ironclaw_engine::{
|
|
ActionDef, ActionResult, Capability, CapabilityLease, CapabilityRegistry, DocId, DocType,
|
|
EffectExecutor, EngineError, LeaseManager, LlmBackend, LlmCallConfig, LlmOutput, LlmResponse,
|
|
MemoryDoc, Mission, MissionId, MissionStatus, PolicyEngine, Project, ProjectId, Step, Store,
|
|
Thread, ThreadConfig, ThreadEvent, ThreadId, ThreadManager, ThreadMessage, ThreadOutcome,
|
|
ThreadState, ThreadType, TokenUsage,
|
|
};
|
|
|
|
use ironclaw_skills::types::ActivationCriteria;
|
|
use ironclaw_skills::v2::{CodeSnippet, SkillMetrics, V2SkillMetadata, V2SkillSource};
|
|
|
|
// ── Scripted LLM ─────────────────────────────────────────────
|
|
|
|
/// Mock LLM that returns pre-queued responses.
|
|
struct ScriptedLlm {
|
|
responses: std::sync::Mutex<Vec<LlmOutput>>,
|
|
}
|
|
|
|
impl ScriptedLlm {
|
|
fn new(responses: Vec<LlmOutput>) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
responses: std::sync::Mutex::new(responses),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl LlmBackend for ScriptedLlm {
|
|
async fn complete(
|
|
&self,
|
|
_messages: &[ThreadMessage],
|
|
_actions: &[ActionDef],
|
|
_config: &LlmCallConfig,
|
|
) -> Result<LlmOutput, EngineError> {
|
|
let mut queue = self.responses.lock().unwrap();
|
|
if queue.is_empty() {
|
|
Ok(LlmOutput {
|
|
response: LlmResponse::Text("done".into()),
|
|
usage: TokenUsage::default(),
|
|
})
|
|
} else {
|
|
Ok(queue.remove(0))
|
|
}
|
|
}
|
|
|
|
fn model_name(&self) -> &str {
|
|
"scripted-mock"
|
|
}
|
|
}
|
|
|
|
// ── HTTP Mock Effects ────────────────────────────────────────
|
|
|
|
/// Mock EffectExecutor that intercepts `http` calls and returns canned responses.
|
|
/// Records all calls for verification.
|
|
struct HttpMockEffects {
|
|
/// Map from URL substring → canned response JSON
|
|
canned_responses: HashMap<String, serde_json::Value>,
|
|
/// Recorded action calls (name, params)
|
|
calls: RwLock<Vec<(String, serde_json::Value)>>,
|
|
}
|
|
|
|
impl HttpMockEffects {
|
|
fn new(canned: HashMap<String, serde_json::Value>) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
canned_responses: canned,
|
|
calls: RwLock::new(Vec::new()),
|
|
})
|
|
}
|
|
|
|
async fn recorded_calls(&self) -> Vec<(String, serde_json::Value)> {
|
|
self.calls.read().await.clone()
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl EffectExecutor for HttpMockEffects {
|
|
async fn execute_action(
|
|
&self,
|
|
action_name: &str,
|
|
parameters: serde_json::Value,
|
|
_lease: &CapabilityLease,
|
|
_context: &ironclaw_engine::ThreadExecutionContext,
|
|
) -> Result<ActionResult, EngineError> {
|
|
self.calls
|
|
.write()
|
|
.await
|
|
.push((action_name.to_string(), parameters.clone()));
|
|
|
|
// Match by URL substring in canned responses
|
|
let url = parameters.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
|
|
|
let output = self
|
|
.canned_responses
|
|
.iter()
|
|
.find(|(pattern, _)| url.contains(pattern.as_str()))
|
|
.map(|(_, response)| response.clone())
|
|
.unwrap_or_else(|| {
|
|
serde_json::json!({
|
|
"error": "not_found",
|
|
"message": format!("No canned response for URL: {url}")
|
|
})
|
|
});
|
|
|
|
Ok(ActionResult {
|
|
call_id: String::new(),
|
|
action_name: action_name.to_string(),
|
|
output,
|
|
is_error: false,
|
|
duration: Duration::from_millis(1),
|
|
})
|
|
}
|
|
|
|
async fn available_actions(
|
|
&self,
|
|
_leases: &[CapabilityLease],
|
|
) -> Result<Vec<ActionDef>, EngineError> {
|
|
Ok(vec![ActionDef {
|
|
name: "http".into(),
|
|
description: "Make HTTP requests".into(),
|
|
parameters_schema: serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"method": {"type": "string"},
|
|
"url": {"type": "string"},
|
|
"headers": {"type": "array"},
|
|
"body": {}
|
|
},
|
|
"required": ["url"]
|
|
}),
|
|
effects: vec![EffectType::ReadExternal],
|
|
requires_approval: false,
|
|
}])
|
|
}
|
|
}
|
|
|
|
// ── In-Memory Store ──────────────────────────────────────────
|
|
|
|
/// Minimal in-memory Store for integration tests.
|
|
struct TestStore {
|
|
threads: RwLock<HashMap<ThreadId, Thread>>,
|
|
events: RwLock<Vec<ThreadEvent>>,
|
|
docs: RwLock<Vec<MemoryDoc>>,
|
|
missions: RwLock<Vec<Mission>>,
|
|
leases: RwLock<Vec<CapabilityLease>>,
|
|
steps: RwLock<Vec<Step>>,
|
|
}
|
|
|
|
impl TestStore {
|
|
fn new() -> Arc<Self> {
|
|
Arc::new(Self {
|
|
threads: RwLock::new(HashMap::new()),
|
|
events: RwLock::new(Vec::new()),
|
|
docs: RwLock::new(Vec::new()),
|
|
missions: RwLock::new(Vec::new()),
|
|
leases: RwLock::new(Vec::new()),
|
|
steps: RwLock::new(Vec::new()),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Store for TestStore {
|
|
async fn save_thread(&self, thread: &Thread) -> Result<(), EngineError> {
|
|
self.threads.write().await.insert(thread.id, thread.clone());
|
|
Ok(())
|
|
}
|
|
async fn load_thread(&self, id: ThreadId) -> Result<Option<Thread>, EngineError> {
|
|
Ok(self.threads.read().await.get(&id).cloned())
|
|
}
|
|
async fn list_threads(
|
|
&self,
|
|
pid: ProjectId,
|
|
_user_id: &str,
|
|
) -> Result<Vec<Thread>, EngineError> {
|
|
Ok(self
|
|
.threads
|
|
.read()
|
|
.await
|
|
.values()
|
|
.filter(|t| t.project_id == pid)
|
|
.cloned()
|
|
.collect())
|
|
}
|
|
async fn update_thread_state(
|
|
&self,
|
|
id: ThreadId,
|
|
state: ThreadState,
|
|
) -> Result<(), EngineError> {
|
|
if let Some(t) = self.threads.write().await.get_mut(&id) {
|
|
t.state = state;
|
|
}
|
|
Ok(())
|
|
}
|
|
async fn save_step(&self, step: &Step) -> Result<(), EngineError> {
|
|
self.steps.write().await.push(step.clone());
|
|
Ok(())
|
|
}
|
|
async fn load_steps(&self, tid: ThreadId) -> Result<Vec<Step>, EngineError> {
|
|
Ok(self
|
|
.steps
|
|
.read()
|
|
.await
|
|
.iter()
|
|
.filter(|s| s.thread_id == tid)
|
|
.cloned()
|
|
.collect())
|
|
}
|
|
async fn append_events(&self, events: &[ThreadEvent]) -> Result<(), EngineError> {
|
|
self.events.write().await.extend_from_slice(events);
|
|
Ok(())
|
|
}
|
|
async fn load_events(&self, tid: ThreadId) -> Result<Vec<ThreadEvent>, EngineError> {
|
|
Ok(self
|
|
.events
|
|
.read()
|
|
.await
|
|
.iter()
|
|
.filter(|e| e.thread_id == tid)
|
|
.cloned()
|
|
.collect())
|
|
}
|
|
async fn save_project(&self, _: &Project) -> Result<(), EngineError> {
|
|
Ok(())
|
|
}
|
|
async fn load_project(&self, _: ProjectId) -> Result<Option<Project>, EngineError> {
|
|
Ok(None)
|
|
}
|
|
async fn save_memory_doc(&self, doc: &MemoryDoc) -> Result<(), EngineError> {
|
|
let mut docs = self.docs.write().await;
|
|
docs.retain(|d| d.id != doc.id);
|
|
docs.push(doc.clone());
|
|
Ok(())
|
|
}
|
|
async fn load_memory_doc(&self, id: DocId) -> Result<Option<MemoryDoc>, EngineError> {
|
|
Ok(self.docs.read().await.iter().find(|d| d.id == id).cloned())
|
|
}
|
|
async fn list_memory_docs(
|
|
&self,
|
|
pid: ProjectId,
|
|
_user_id: &str,
|
|
) -> Result<Vec<MemoryDoc>, EngineError> {
|
|
Ok(self
|
|
.docs
|
|
.read()
|
|
.await
|
|
.iter()
|
|
.filter(|d| d.project_id == pid)
|
|
.cloned()
|
|
.collect())
|
|
}
|
|
async fn list_memory_docs_by_owner(
|
|
&self,
|
|
user_id: &str,
|
|
) -> Result<Vec<MemoryDoc>, EngineError> {
|
|
Ok(self
|
|
.docs
|
|
.read()
|
|
.await
|
|
.iter()
|
|
.filter(|d| d.user_id == user_id)
|
|
.cloned()
|
|
.collect())
|
|
}
|
|
async fn save_lease(&self, lease: &CapabilityLease) -> Result<(), EngineError> {
|
|
self.leases.write().await.push(lease.clone());
|
|
Ok(())
|
|
}
|
|
async fn load_active_leases(&self, _: ThreadId) -> Result<Vec<CapabilityLease>, EngineError> {
|
|
Ok(vec![])
|
|
}
|
|
async fn revoke_lease(&self, _: LeaseId, _: &str) -> Result<(), EngineError> {
|
|
Ok(())
|
|
}
|
|
async fn save_mission(&self, m: &Mission) -> Result<(), EngineError> {
|
|
let mut missions = self.missions.write().await;
|
|
missions.retain(|x| x.id != m.id);
|
|
missions.push(m.clone());
|
|
Ok(())
|
|
}
|
|
async fn load_mission(&self, id: MissionId) -> Result<Option<Mission>, EngineError> {
|
|
Ok(self
|
|
.missions
|
|
.read()
|
|
.await
|
|
.iter()
|
|
.find(|m| m.id == id)
|
|
.cloned())
|
|
}
|
|
async fn list_missions(
|
|
&self,
|
|
pid: ProjectId,
|
|
_user_id: &str,
|
|
) -> Result<Vec<Mission>, EngineError> {
|
|
Ok(self
|
|
.missions
|
|
.read()
|
|
.await
|
|
.iter()
|
|
.filter(|m| m.project_id == pid)
|
|
.cloned()
|
|
.collect())
|
|
}
|
|
async fn update_mission_status(
|
|
&self,
|
|
_: MissionId,
|
|
_: MissionStatus,
|
|
) -> Result<(), EngineError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────
|
|
|
|
fn make_github_skill_doc(project_id: ProjectId) -> MemoryDoc {
|
|
let meta = V2SkillMetadata {
|
|
name: "github".into(),
|
|
version: 1,
|
|
description: "GitHub API integration via HTTP tool".into(),
|
|
activation: ActivationCriteria {
|
|
keywords: vec![
|
|
"github".into(),
|
|
"issues".into(),
|
|
"pull request".into(),
|
|
"repository".into(),
|
|
],
|
|
patterns: vec![
|
|
r"(?i)(list|show|get|fetch).*issue".into(),
|
|
],
|
|
tags: vec!["git".into(), "devops".into()],
|
|
max_context_tokens: 1500,
|
|
..Default::default()
|
|
},
|
|
source: V2SkillSource::Authored,
|
|
trust: ironclaw_skills::SkillTrust::Trusted,
|
|
requires: Default::default(),
|
|
code_snippets: vec![CodeSnippet {
|
|
name: "list_github_issues".into(),
|
|
code: r#"def list_github_issues(owner, repo, state="open"):
|
|
result = await http(method="GET", url=f"https://api.github.com/repos/{owner}/{repo}/issues?state={state}&per_page=10")
|
|
return result"#
|
|
.into(),
|
|
description: "List issues for a GitHub repository".into(),
|
|
}],
|
|
metrics: SkillMetrics::default(),
|
|
parent_version: None,
|
|
revisions: vec![],
|
|
repairs: vec![],
|
|
content_hash: String::new(),
|
|
bundle_path: None,
|
|
source_url: None,
|
|
};
|
|
|
|
let prompt = "\
|
|
# GitHub API Skill
|
|
|
|
Use the `http` tool to call the GitHub REST API. Credentials are injected automatically.
|
|
|
|
## Patterns
|
|
|
|
- List issues: `await http(method=\"GET\", url=\"https://api.github.com/repos/{owner}/{repo}/issues?state=open\")`
|
|
- Create issue: `await http(method=\"POST\", url=\"...issues\", body={\"title\": \"...\"})`
|
|
|
|
## Rules
|
|
- Always use HTTPS
|
|
- Do NOT set Authorization headers manually
|
|
- Default to state=open for issue queries
|
|
";
|
|
|
|
let mut doc = MemoryDoc::new(project_id, "system", DocType::Skill, "skill:github", prompt);
|
|
doc.metadata = serde_json::to_value(&meta).unwrap();
|
|
doc
|
|
}
|
|
|
|
fn canned_github_issues() -> serde_json::Value {
|
|
serde_json::json!([
|
|
{"number": 42, "title": "Fix login bug", "state": "open", "user": {"login": "alice"}},
|
|
{"number": 37, "title": "Add dark mode", "state": "open", "user": {"login": "bob"}},
|
|
{"number": 15, "title": "Update docs", "state": "open", "user": {"login": "carol"}}
|
|
])
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
/// Full CodeAct E2E: skill selected → LLM returns code → http() dispatched →
|
|
/// canned response returned → FINAL() terminates → thread completes.
|
|
#[tokio::test]
|
|
async fn skill_codeact_e2e_github_issues() {
|
|
let project_id = ProjectId::new();
|
|
|
|
// 1. Build GitHub skill doc (stored in TestStore for Python orchestrator to find)
|
|
let skill_doc = make_github_skill_doc(project_id);
|
|
|
|
// 2. Script the LLM: return Python code that awaits http() then FINAL()
|
|
let python_code = r#"
|
|
result = await http(method="GET", url="https://api.github.com/repos/test-org/test-repo/issues?state=open&per_page=5")
|
|
FINAL(str(result))
|
|
"#;
|
|
let llm = ScriptedLlm::new(vec![LlmOutput {
|
|
response: LlmResponse::Code {
|
|
code: python_code.to_string(),
|
|
content: None,
|
|
},
|
|
usage: TokenUsage::default(),
|
|
}]);
|
|
|
|
// 3. Mock HTTP effects with canned GitHub response
|
|
let mut canned = HashMap::new();
|
|
canned.insert(
|
|
"api.github.com/repos/test-org/test-repo/issues".to_string(),
|
|
canned_github_issues(),
|
|
);
|
|
let effects = HttpMockEffects::new(canned);
|
|
|
|
// 4. Build infrastructure — store skill doc so __list_skills__() finds it
|
|
let store = TestStore::new();
|
|
store.save_memory_doc(&skill_doc).await.unwrap();
|
|
|
|
let mut caps = CapabilityRegistry::new();
|
|
caps.register(Capability {
|
|
name: "tools".into(),
|
|
description: "Available tools".into(),
|
|
actions: vec![ActionDef {
|
|
name: "http".into(),
|
|
description: "Make HTTP requests".into(),
|
|
parameters_schema: serde_json::json!({"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}),
|
|
effects: vec![EffectType::ReadExternal],
|
|
requires_approval: false,
|
|
}],
|
|
knowledge: vec![],
|
|
policies: vec![],
|
|
});
|
|
|
|
let mgr = ThreadManager::new(
|
|
llm,
|
|
effects.clone(),
|
|
store.clone() as Arc<dyn Store>,
|
|
Arc::new(caps),
|
|
Arc::new(LeaseManager::new()),
|
|
Arc::new(PolicyEngine::new()),
|
|
);
|
|
|
|
// 5. Spawn thread with a goal that matches the GitHub skill keywords
|
|
// (Python orchestrator calls __list_skills__() and selects based on goal)
|
|
let tid = mgr
|
|
.spawn_thread(
|
|
"show me open github issues for test-org/test-repo",
|
|
ThreadType::Foreground,
|
|
project_id,
|
|
ThreadConfig::default(),
|
|
None,
|
|
"test-user",
|
|
)
|
|
.await
|
|
.expect("spawn_thread");
|
|
|
|
// 6. Wait for completion
|
|
let outcome = mgr.join_thread(tid).await.expect("join_thread");
|
|
|
|
// 7. Verify thread completed with the canned response data
|
|
match &outcome {
|
|
ThreadOutcome::Completed { response } => {
|
|
let resp = response.as_deref().unwrap_or("");
|
|
assert!(
|
|
resp.contains("Fix login bug") || resp.contains("42"),
|
|
"response should contain canned issue data, got: {resp}"
|
|
);
|
|
}
|
|
other => panic!("expected Completed, got: {other:?}"),
|
|
}
|
|
|
|
// 8. Verify the http action was called with correct parameters
|
|
let calls = effects.recorded_calls().await;
|
|
assert!(
|
|
!calls.is_empty(),
|
|
"http action should have been called at least once"
|
|
);
|
|
let (action_name, params) = &calls[0];
|
|
assert_eq!(action_name, "http");
|
|
let url = params.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
|
assert!(
|
|
url.contains("api.github.com") && url.contains("test-org/test-repo/issues"),
|
|
"http should be called with GitHub issues URL, got: {url}"
|
|
);
|
|
|
|
// 9. Verify skill content was injected into the internal working transcript.
|
|
let thread = store.load_thread(tid).await.unwrap().unwrap();
|
|
let has_skill_content = thread
|
|
.internal_messages
|
|
.iter()
|
|
.any(|m| m.content.contains("Active Skills") || m.content.contains("GitHub API Skill"));
|
|
assert!(
|
|
has_skill_content,
|
|
"thread internal_messages should contain injected skill content"
|
|
);
|
|
}
|
|
|
|
/// Verify selected skill provenance is persisted onto the thread for learning flows.
|
|
#[tokio::test]
|
|
async fn skill_codeact_persists_active_skill_provenance() {
|
|
let project_id = ProjectId::new();
|
|
let skill_doc = make_github_skill_doc(project_id);
|
|
let skill_doc_id = skill_doc.id;
|
|
|
|
let python_code = r#"
|
|
result = await http(method="GET", url="https://api.github.com/repos/test-org/test-repo/issues?state=open&per_page=5")
|
|
FINAL(str(result))
|
|
"#;
|
|
let llm = ScriptedLlm::new(vec![LlmOutput {
|
|
response: LlmResponse::Code {
|
|
code: python_code.to_string(),
|
|
content: None,
|
|
},
|
|
usage: TokenUsage::default(),
|
|
}]);
|
|
|
|
let mut canned = HashMap::new();
|
|
canned.insert(
|
|
"api.github.com/repos/test-org/test-repo/issues".to_string(),
|
|
canned_github_issues(),
|
|
);
|
|
let effects = HttpMockEffects::new(canned);
|
|
let store = TestStore::new();
|
|
store.save_memory_doc(&skill_doc).await.unwrap();
|
|
|
|
let mut caps = CapabilityRegistry::new();
|
|
caps.register(Capability {
|
|
name: "tools".into(),
|
|
description: "Available tools".into(),
|
|
actions: vec![ActionDef {
|
|
name: "http".into(),
|
|
description: "Make HTTP requests".into(),
|
|
parameters_schema: serde_json::json!({"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}),
|
|
effects: vec![EffectType::ReadExternal],
|
|
requires_approval: false,
|
|
}],
|
|
knowledge: vec![],
|
|
policies: vec![],
|
|
});
|
|
|
|
let mgr = ThreadManager::new(
|
|
llm,
|
|
effects,
|
|
store.clone() as Arc<dyn Store>,
|
|
Arc::new(caps),
|
|
Arc::new(LeaseManager::new()),
|
|
Arc::new(PolicyEngine::new()),
|
|
);
|
|
|
|
let tid = mgr
|
|
.spawn_thread(
|
|
"show me open github issues for test-org/test-repo",
|
|
ThreadType::Foreground,
|
|
project_id,
|
|
ThreadConfig::default(),
|
|
None,
|
|
"test-user",
|
|
)
|
|
.await
|
|
.expect("spawn_thread");
|
|
|
|
let outcome = mgr.join_thread(tid).await.expect("join_thread");
|
|
assert!(
|
|
matches!(outcome, ThreadOutcome::Completed { .. }),
|
|
"expected Completed, got: {outcome:?}"
|
|
);
|
|
|
|
let thread = store.load_thread(tid).await.unwrap().unwrap();
|
|
let active_skills = thread.active_skills();
|
|
let github_skill = active_skills
|
|
.iter()
|
|
.find(|skill| skill.doc_id == skill_doc_id)
|
|
.unwrap_or_else(|| panic!("expected github skill provenance in {active_skills:?}"));
|
|
assert_eq!(github_skill.name, "github");
|
|
assert_eq!(github_skill.version, 1);
|
|
assert_eq!(github_skill.snippet_names, vec!["list_github_issues"]);
|
|
}
|
|
|
|
/// Verify that non-matching goals don't activate skills (negative case).
|
|
#[tokio::test]
|
|
async fn non_matching_goal_skips_skill_codeact() {
|
|
let project_id = ProjectId::new();
|
|
|
|
let skill_doc = make_github_skill_doc(project_id);
|
|
|
|
// LLM just returns text — no code execution needed
|
|
let llm = ScriptedLlm::new(vec![LlmOutput {
|
|
response: LlmResponse::Text("The weather is sunny.".into()),
|
|
usage: TokenUsage::default(),
|
|
}]);
|
|
|
|
let effects = HttpMockEffects::new(HashMap::new());
|
|
let store = TestStore::new();
|
|
store.save_memory_doc(&skill_doc).await.unwrap();
|
|
|
|
let mgr = ThreadManager::new(
|
|
llm,
|
|
effects.clone(),
|
|
store.clone() as Arc<dyn Store>,
|
|
Arc::new(CapabilityRegistry::new()),
|
|
Arc::new(LeaseManager::new()),
|
|
Arc::new(PolicyEngine::new()),
|
|
);
|
|
|
|
let tid = mgr
|
|
.spawn_thread(
|
|
"what is the weather today",
|
|
ThreadType::Foreground,
|
|
project_id,
|
|
ThreadConfig::default(),
|
|
None,
|
|
"test-user",
|
|
)
|
|
.await
|
|
.expect("spawn_thread");
|
|
|
|
let outcome = mgr.join_thread(tid).await.expect("join_thread");
|
|
assert!(matches!(outcome, ThreadOutcome::Completed { .. }));
|
|
|
|
// No http calls should have been made
|
|
let calls = effects.recorded_calls().await;
|
|
assert!(calls.is_empty(), "no http calls for weather query");
|
|
|
|
// Skill content should NOT appear in messages (goal doesn't match)
|
|
let thread = store.load_thread(tid).await.unwrap().unwrap();
|
|
let has_skill_content = thread
|
|
.messages
|
|
.iter()
|
|
.any(|m| m.content.contains("Active Skills"));
|
|
assert!(!has_skill_content, "no skills for unrelated goal");
|
|
}
|