Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions crates/ironclaw_reborn_composition/src/extension_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ impl RebornLocalExtensionManagementPort {
);
if extension_search_has_installed_external_channel_result(response.payload.as_ref()) {
response.message = Some(
"Search found installed external channel results. Search cannot prove the calling user's channel account is personally connected, so do not treat those results as ready for delivery or message access. Call builtin.extension_activate now for the matching extension id; activation surfaces the channel-specific pairing/setup instructions and, for proof-code flows, the user must paste the code into the WebChat connection panel rather than normal chat."
"Search found installed external channel results. Search cannot prove the calling user's channel account is personally connected. For an explicit connect, pair, authenticate, or account-access request, call builtin.extension_activate for the matching extension id so channel-specific pairing/setup instructions can be surfaced. For routine, trigger, or notification delivery, prefer the configured outbound delivery target when one is available; do not activate the channel just to send to an already configured delivery target."
.to_string(),
);
Comment on lines 224 to 227

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guidance text duplicated by hand across two files.

This search() message and the builtin.extension_search manifest description in extension_lifecycle_capabilities.rs (line 71) both encode the same "prefer the configured outbound delivery target over activating" rule in independently-worded, hand-maintained strings, only cross-checked via substring assertions in each file's tests. A future edit to one could silently drift from the other since nothing enforces they agree.

Consider extracting the shared clause(s) into a const/helper so both call sites compose from one source of truth.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_reborn_composition/src/extension_lifecycle.rs` around lines
224 - 227, The guidance text in search() is duplicated with the
builtin.extension_search manifest description, so the two hand-maintained
strings can drift. Extract the shared “prefer the configured outbound delivery
target over activating” wording into a shared const/helper and have
extension_lifecycle.rs and extension_lifecycle_capabilities.rs compose their
messages from that single source of truth. Update the related tests to assert
against the shared text via the symbols search() and builtin.extension_search so
both call sites stay aligned.

} else if extension_search_has_ready_result(response.payload.as_ref()) {
Expand Down Expand Up @@ -1782,7 +1782,7 @@ mod tests {
}

#[tokio::test]
async fn extension_search_routes_active_external_channel_to_activation_pairing_flow() {
async fn extension_search_distinguishes_external_channel_connect_from_delivery() {
let (_dir, _storage_root, facade, _active_registry, _installation_store) =
extension_lifecycle_fixture_with_catalog_and_service(
AvailableExtensionCatalog::from_packages(vec![fixture_external_channel_package(
Expand Down Expand Up @@ -1824,11 +1824,11 @@ mod tests {
let message = search.message.as_deref().expect("search guidance");
assert!(
message.contains("external channel")
&& message.contains("do not treat")
&& message.contains("explicit connect")
&& message.contains("builtin.extension_activate")
&& message.contains("WebChat connection panel")
&& message.contains("rather than normal chat"),
"active external channel search should route models into activation/pairing, got: {message}"
&& message.contains("outbound delivery target")
&& message.contains("do not activate"),
"active external channel search should distinguish connect requests from delivery, got: {message}"
);
assert!(
!message.contains("Treat those results as ready"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ fn manifests() -> Result<Vec<CapabilityManifest>, ExtensionError> {
Ok(vec![
lifecycle_manifest(
EXTENSION_SEARCH_CAPABILITY_ID,
"Search the local Reborn extension catalog by extension, product, provider, or service name. The catalog includes host-bundled extensions that are not installed yet and installed extensions that are inactive. For connect, enable, install, or integrate requests, use this for discovery only, then continue with builtin.extension_install for the matching extension instead of asking the user to configure credentials from search results. If search returns an installed external channel, still call builtin.extension_activate so channel-specific pairing/setup instructions can be surfaced before claiming the channel is ready.",
"Search the local Reborn extension catalog by extension, product, provider, or service name. The catalog includes host-bundled extensions that are not installed yet and installed extensions that are inactive. For connect, enable, install, pair, authenticate, or integrate requests, use this for discovery only, then continue with builtin.extension_install or builtin.extension_activate for the matching extension instead of asking the user to configure credentials from search results. For routine, trigger, or notification delivery, prefer configured outbound delivery targets before activating an external channel.",
vec![EffectKind::ReadFilesystem],
PermissionMode::Allow,
)?,
Expand Down Expand Up @@ -604,6 +604,7 @@ mod tests {
&& search.description.contains("service name")
&& search.description.contains("discovery only")
&& search.description.contains("external channel")
&& search.description.contains("outbound delivery targets")
&& search
.description
.contains(EXTENSION_ACTIVATE_CAPABILITY_ID)
Expand Down
155 changes: 82 additions & 73 deletions scripts/reborn_webui_v2_live_qa/run_live_qa.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,15 @@
Expected result: Slack is connected""",
"qa_3b_endpoint_status_live_chat": """In WebUI, ask IronClaw "check if near.ai returns a 200 status."
Expected result: IronClaw reports the endpoint's current HTTP status""",
"qa_3c_endpoint_status_slack_routine": """In WebUI, ask IronClaw, "Every 5 minutes, ping [endpoint URL] checking if it returns a 200 status and send result in a DM in slack"
"qa_3c_endpoint_status_slack_routine": """In WebUI, ask IronClaw, "Every 5 minutes, ping [endpoint URL] checking if it returns a 200 status and send result in a DM in slack."
Expected result: Routine created""",
"qa_4a_gmail_connect": """In WebUI, ask IronClaw "connect to Gmail." Go through the flow w/ Gmail.
Expected result: Gmail is connected""",
"qa_4b_github_connect": """In WebUI, ask IronClaw "connect to GitHub." Go through the auth flow.
Expected result: GitHub is connected""",
"qa_4c_github_release_live_chat": """In WebUI, ask IronClaw "summarize the latest release from https://github.com/nearai/ironclaw."
Expected result: summary of the most recent release""",
"qa_4d_github_release_slack_routine": """In WebUI, ask IronClaw, "Every 5 minutes, check https://github.com/nearai/ironclaw for latest releases and send me a Slack message summarizing any new ones."
"qa_4d_github_release_slack_routine": """In WebUI, ask IronClaw, "Every 5 minutes, check https://github.com/nearai/ironclaw for latest releases and send me a Slack DM summarizing any new ones."
Expected result: Routine created""",
"qa_5a_slack_connect": """In WebUI, ask IronClaw "connect to Slack." Go through the auth flow.
Expected result: Slack is connected""",
Expand All @@ -162,8 +162,8 @@
Expected result: ABC sheet has new rows for each near.ai inbound email""",
"qa_6d_gmail_to_sheet_routine": """In WebUI, ask IronClaw, "Every 30 minutes, check my inbox and add any new emails from a near.ai address to my Google Sheet called ABC."
Expected result: Routine created""",
"qa_7a_slack_product_channel_connect": """In WebUI, ask IronClaw "connect to Slack, using channel #product." Go through the flow
Expected result: Slack channel is connected""",
"qa_7a_slack_product_channel_connect": """Verify Slack DM delivery target preflight configuration before Slack workflow cases run.
Expected result: Slack DM delivery target is configured""",
"qa_7b_sheets_connect": """In WebUI, ask IronClaw "connect to Google Sheets." Go through the auth flow.
Expected result: Google Sheets is connected""",
"qa_7c_slack_bug_logger_routine": """In WebUI, ask IronClaw "whenever I send a slack message starting with 'bug:', add it as a row to my bug logging Google Sheet."
Expand All @@ -174,7 +174,7 @@
Expected result: Slack is connected""",
"qa_8b_hn_keyword_live_chat": """In WebUI, ask IronClaw "search Hacker News for any recent posts mentioning 'IronClaw' or 'NEAR AI'."
Expected result: IronClaw reports any matching HN posts""",
"qa_8c_hn_keyword_slack_routine": """In WebUI, ask IronClaw, "Every hour, check Hacker News for new posts mentioning 'IronClaw' or 'NEAR AI' and send a summary to Slack."
"qa_8c_hn_keyword_slack_routine": """In WebUI, ask IronClaw, "Every hour, check Hacker News for new posts mentioning 'IronClaw' or 'NEAR AI' and send a summary to Slack DM."
Expected result: Routine created""",
}

Expand All @@ -200,11 +200,6 @@ def _qa_sheet_prompt(case_name: str) -> str:
EXTENSION_INSTALL_CAPABILITY_ID = "builtin.extension_install"
EXTENSION_ACTIVATE_CAPABILITY_ID = "builtin.extension_activate"
OUTBOUND_DELIVERY_TARGETS_LIST_CAPABILITY_ID = "builtin.outbound_delivery_targets_list"
QA_7A_CHAT_CONNECT_CAPABILITY_IDS = [
EXTENSION_SEARCH_CAPABILITY_ID,
EXTENSION_INSTALL_CAPABILITY_ID,
EXTENSION_ACTIVATE_CAPABILITY_ID,
]
QA_7C_BUG_LOGGING_SHEET_TITLE = "bug logging Google Sheet"


Expand Down Expand Up @@ -1049,6 +1044,31 @@ def _record_assistant_reply_wait_result(
observed["semantic_judge"] = reply.semantic_judge


def _routine_confirmation_follow_up_for_text(text: str) -> str | None:
normalized = text.lower()
asks_for_timezone = "timezone" in normalized or "time zone" in normalized
asks_for_confirmation = any(
phrase in normalized
for phrase in (
"confirm",
"go ahead",
"shall i",
"should i",
"would you like",
)
)
routine_context = any(
phrase in normalized
for phrase in ("routine", "trigger", "automation", "schedule", "cron")
)
if routine_context and (asks_for_timezone or asks_for_confirmation):
return (
"Yes, go ahead and create it. Use Europe/London (London time) "
"for the schedule."
)
return None


async def _live_chat_case(
ctx: LiveQaContext,
*,
Expand All @@ -1059,6 +1079,7 @@ async def _live_chat_case(
timeout: float = 120.0,
extra_details: dict[str, object] | None = None,
forbidden_text: list[str] | None = None,
routine_confirmation_follow_up: bool = False,
) -> ProbeResult:
from playwright.async_api import expect

Expand Down Expand Up @@ -1091,16 +1112,35 @@ async def action(page: object) -> None:
prompt[:80],
timeout=15000,
)
_record_assistant_reply_wait_result(
observed,
await _wait_for_assistant_reply(
page,
marker=marker,
required_text=required_text,
timeout=timeout,
semantic_goal=prompt,
),
reply = await _wait_for_assistant_reply(
page,
marker=marker,
required_text=required_text,
timeout=timeout,
semantic_goal=prompt,
)
_record_assistant_reply_wait_result(observed, reply)
if routine_confirmation_follow_up:
follow_up = _routine_confirmation_follow_up_for_text(reply.text_excerpt)
if follow_up:
observed["routine_confirmation_follow_up_sent"] = follow_up
observed["routine_confirmation_initial_text_excerpt"] = (
reply.text_excerpt
)
await composer.fill(follow_up)
await composer.press("Enter")
await expect(page.locator("[data-testid='msg-user']").last).to_contain_text( # type: ignore[attr-defined]
follow_up[:80],
timeout=15000,
)
follow_up_reply = await _wait_for_assistant_reply(
page,
marker=marker,
required_text=required_text,
timeout=timeout,
semantic_goal=f"{prompt}\n{follow_up}",
)
_record_assistant_reply_wait_result(observed, follow_up_reply)
if forbidden_text:
text = str(observed["text_excerpt"]).lower()
matches = [
Expand Down Expand Up @@ -1396,6 +1436,17 @@ async def _live_github_latest_release(owner: str, repo: str) -> dict[str, str]:
"Accept": "application/vnd.github+json",
"User-Agent": "ironclaw-reborn-webui-v2-live-qa",
}
token = _first_env_value(
[
"AUTH_LIVE_GITHUB_TOKEN",
"IRONCLAW_REBORN_GITHUB_TOKEN",
"LIVE_CANARY_GITHUB_TOKEN",
"GITHUB_TOKEN",
"GH_TOKEN",
]
)
if token:
headers["Authorization"] = f"Bearer {token[1]}"
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True, headers=headers) as client:
response = await client.get(url)
response.raise_for_status()
Expand Down Expand Up @@ -1929,6 +1980,11 @@ def _slack_preflight(ctx: LiveQaContext) -> dict[str, object]:
return slack


def _slack_connect_instructions_look_valid(instructions: str) -> bool:
text = instructions.lower()
return "message the slack app" in text or ("slack" in text and "pairing code" in text)
Comment on lines +1983 to +1985

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== run_live_qa.py slice ==\n'
sed -n '1910,2005p' scripts/reborn_webui_v2_live_qa/run_live_qa.py

printf '\n== search to_contain_text usages ==\n'
rg -n "to_contain_text\(" scripts/reborn_webui_v2_live_qa/run_live_qa.py scripts -g '!**/node_modules/**' || true

printf '\n== relevant repo guidance ==\n'
rg -n "Playwright|to_contain_text|ignoreCase|ignore_case|pairing code|slack connect" -g 'CLAUDE.md' -g 'AGENTS.md' -g '.claude/**' -g '*.md' .

Repository: nearai/ironclaw

Length of output: 18420


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Playwright docs quick check ==\n'
python3 - <<'PY'
import urllib.request
url = "https://playwright.dev/python/docs/api/class-locatorassertions#locator-assertions-to-contain-text"
with urllib.request.urlopen(url, timeout=20) as r:
    text = r.read().decode("utf-8", "ignore")
for needle in ["ignore_case", "ignoreCase", "Case-insensitive"]:
    idx = text.find(needle)
    print(f"{needle}: {idx}")
    if idx != -1:
        print(text[max(0, idx-200): idx+500])
PY

Repository: nearai/ironclaw

Length of output: 2782


🌐 Web query:

Playwright Python expect(locator).to_contain_text default case sensitivity ignore_case documentation

💡 Result:

In Playwright Python, the expect(locator).to_contain_text() assertion performs a case-sensitive match by default when using a string [1][2]. To perform a case-insensitive match, you must explicitly set the ignore_case option to True [1][2]: python from playwright.sync_api import expect # Case-insensitive assertion expect(locator).to_contain_text("Expected Text", ignore_case=True) The ignore_case option was added in version 1.23 [1][2]. When provided, it takes precedence over the corresponding flags in regular expressions if one is passed instead of a string [1][2]. Note that this behavior is specific to the expect(locator).to_contain_text() assertion [1]. Other Playwright features, such as page.get_by_text() or the :has-text() selector, have different default behaviors regarding case sensitivity [3][4].

Citations:


Align the Slack body check with the accepted instruction shapes. The helper allows either "message the slack app" or "slack" + "pairing code", but the later to_contain_text("pairing code") runs unconditionally and is case-sensitive by default. Gate that assertion on the matched branch or use ignore_case=True.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/reborn_webui_v2_live_qa/run_live_qa.py` around lines 1932 - 1934, The
Slack instruction validator in _slack_connect_instructions_look_valid accepts
two shapes, but the later pairing-code text check is always applied and may fail
for the “message the slack app” branch. Update the logic around this helper so
the to_contain_text("pairing code") assertion only runs when the
Slack/pairing-code branch matched, or make that assertion case-insensitive with
ignore_case=True. Use the existing _slack_connect_instructions_look_valid and
to_contain_text call sites to keep the branching consistent.



async def _slack_connect_case(ctx: LiveQaContext, *, case_name: str) -> ProbeResult:
from playwright.async_api import expect

Expand Down Expand Up @@ -1978,10 +2034,10 @@ async def action(page: object) -> None:
if not title:
raise AssertionError(f"Slack connect action title missing: {personal!r}")
instructions = str(action_body.get("instructions") or "")
if "Message the Slack app" not in instructions:
if not _slack_connect_instructions_look_valid(instructions):
raise AssertionError(f"unexpected Slack connect instructions: {instructions!r}")
await expect(page.locator("body")).to_contain_text(title, timeout=15000) # type: ignore[attr-defined]
await expect(page.locator("body")).to_contain_text("Message the Slack app", timeout=15000) # type: ignore[attr-defined]
await expect(page.locator("body")).to_contain_text("pairing code", timeout=15000) # type: ignore[attr-defined]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The helper _slack_connect_instructions_look_valid was introduced to support both the old instructions ("Message the Slack app...") and the new instructions ("Message the IronClaw Reborn app in Slack to get a pairing code..."). However, the strict assertion await expect(page.locator("body")).to_contain_text("pairing code", timeout=15000) will fail if the live environment still serves the old instructions, as they do not contain the substring "pairing code".

To ensure the stabilization is robust and backward-compatible as intended, use a regular expression that matches either instruction variant.

Suggested change
await expect(page.locator("body")).to_contain_text("pairing code", timeout=15000) # type: ignore[attr-defined]
await expect(page.locator("body")).to_contain_text(re.compile(r"Message the Slack app|pairing code"), timeout=15000) # type: ignore[attr-defined]

observed["slack_display_name"] = personal.get("display_name")
observed["slack_connect_title"] = title
observed["slack_connect_instructions"] = instructions
Expand Down Expand Up @@ -2952,6 +3008,7 @@ async def _routine_creation_case(
required_text=required_text,
timeout=180.0,
extra_details=details,
routine_confirmation_follow_up=True,
)
after_count = _trigger_record_count(ctx.reborn_home, count_name)
result.details["trigger_records_after"] = after_count
Expand Down Expand Up @@ -3007,11 +3064,7 @@ async def _slack_delivery_routine_case(
f"{routine_instruction} The routine's final answer and Slack message must "
f"include the exact marker {delivery_marker}. Create the routine now; do not "
"run it immediately. During routine creation, do not perform the routine's "
"live check, web/search/HTTP lookup, or Slack send. Before calling trigger_create, "
"call builtin__outbound_delivery_targets_list, then call "
"builtin__outbound_delivery_target_set with the Slack target id returned by the "
"list tool; do not only mention the target in text. Then create the routine "
"definition. "
"live check, web/search/HTTP lookup, or Slack send. "
f"In your final answer include the exact marker {creation_marker} and include "
"the text routine."
),
Expand Down Expand Up @@ -3509,11 +3562,6 @@ async def case_qa_7c_slack_bug_logger_routine(ctx: LiveQaContext) -> ProbeResult
required_text=["trigger|routine|automation|cron|schedule|fires|watches", "bug"],
prompt=_qa_sheet_prompt("qa_7c_slack_bug_logger_routine"),
extensions=[
{
"package_id": "slack",
"display_name": "Slack",
"required_tools": [],
},
{
"package_id": "google-drive",
"display_name": "Google Drive",
Expand Down Expand Up @@ -3543,16 +3591,11 @@ async def case_qa_7c_slack_bug_logger_routine(ctx: LiveQaContext) -> ProbeResult


async def case_qa_7a_slack_product_channel_connect(ctx: LiveQaContext) -> ProbeResult:
from playwright.async_api import expect

started = time.monotonic()
case_name = "qa_7a_slack_product_channel_connect"
prompt = (
'In WebUI, ask IronClaw "connect to Slack for my configured DM delivery '
'target." Go through the flow\n'
"Expected result: Slack DM delivery target is connected"
)
observed: dict[str, object] = {"chat_connect_prompt": prompt}
observed: dict[str, object] = {
"preflight": "Slack DM delivery target is configured before user-story workflow cases"
}
try:
slack = _slack_preflight(ctx)
delivery_channel_id = _slack_delivery_channel_id(ctx)
Expand All @@ -3579,40 +3622,6 @@ async def case_qa_7a_slack_product_channel_connect(ctx: LiveQaContext) -> ProbeR
"Slack live QA delivery target must be a DM to the user; "
f"got channel_id={delivery_channel_id!r}"
)

async def action(page: object) -> None:
capability_ids = QA_7A_CHAT_CONNECT_CAPABILITY_IDS
baseline_statuses = _capability_run_statuses(
ctx.reborn_home,
capability_ids,
)
baseline_completed = _completed_capability_counts(baseline_statuses)
observed["baseline_capability_statuses"] = baseline_statuses
await page.goto(
f"{ctx.base_url}/v2/?token={AUTH_TOKEN}",
wait_until="domcontentloaded",
) # type: ignore[attr-defined]
composer = page.locator("[data-testid='chat-composer']") # type: ignore[attr-defined]
await expect(composer).to_be_visible(timeout=15000)
await composer.fill(prompt)
await composer.press("Enter")
await expect(page.locator("[data-testid='msg-user']").last).to_contain_text( # type: ignore[attr-defined]
prompt[:80],
timeout=15000,
)
_record_assistant_reply_wait_result(
observed,
await _wait_for_assistant_reply(
page,
marker=None,
required_text=["slack", "dm|delivery|target|connected"],
timeout=180.0,
),
)
statuses = _capability_run_statuses(ctx.reborn_home, capability_ids)
observed["capability_statuses"] = statuses

await _with_page(ctx.output_dir, case_name, action)
return _result(case_name, True, started, observed)
except Exception as exc:
return _result(
Expand Down
Loading
Loading