From 600243c7a377dff21ac4de561340c6829c5e9a9c Mon Sep 17 00:00:00 2001 From: Amy Lin Date: Tue, 28 Jul 2026 09:40:08 -0500 Subject: [PATCH 1/9] feat(chat-with-content): add tested helpers and dedicated CI --- .github/workflows/chat-with-content.yml | 74 ++++++ .github/workflows/extensions.yml | 22 +- extensions/chat-with-content/CHANGELOG.md | 7 + extensions/chat-with-content/helpers.py | 105 ++++++-- extensions/chat-with-content/manifest.json | 22 +- extensions/chat-with-content/pyproject.toml | 9 +- extensions/chat-with-content/test_helpers.py | 237 +++++++++++++++++++ 7 files changed, 447 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/chat-with-content.yml create mode 100644 extensions/chat-with-content/test_helpers.py diff --git a/.github/workflows/chat-with-content.yml b/.github/workflows/chat-with-content.yml new file mode 100644 index 00000000..158e1506 --- /dev/null +++ b/.github/workflows/chat-with-content.yml @@ -0,0 +1,74 @@ +name: Chat with Content + +on: + workflow_call: + +# Setup the environment with the extension name for easy re-use +# Also need the GH_TOKEN for the release-extension action to be able to use gh +env: + EXTENSION_NAME: chat-with-content + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + extension: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./extensions/${{ env.EXTENSION_NAME }} + + steps: + # Checkout the repository so the rest of the actions can run with no issue + - uses: actions/checkout@v4 + + # We want to fail quickly if the linting fails, do that first + - uses: ./.github/actions/lint-extension + with: + extension-name: ${{ env.EXTENSION_NAME }} + + # Run the Python tests before packaging. + - uses: astral-sh/setup-uv@v5 + with: + pyproject-file: ./extensions/${{ env.EXTENSION_NAME }}/pyproject.toml + + - name: Run tests + run: uv run pytest + + # Upload only the files the extension needs to run, leaving out tests, + # pyproject.toml, and other repo-only files. + - name: Upload extension files + uses: actions/upload-artifact@v4 + with: + name: ${{ env.EXTENSION_NAME }} + path: | + extensions/${{ env.EXTENSION_NAME }}/app.py + extensions/${{ env.EXTENSION_NAME }}/helpers.py + extensions/${{ env.EXTENSION_NAME }}/requirements.txt + extensions/${{ env.EXTENSION_NAME }}/manifest.json + + # Package up the extension into a TAR using the generalized + # package-extension action + - uses: ./.github/actions/package-extension + with: + extension-name: ${{ env.EXTENSION_NAME }} + artifact-name: ${{ env.EXTENSION_NAME }} + + connect-integration-tests: + needs: extension + uses: ./.github/workflows/connect-integration-tests.yml + secrets: inherit + with: + extensions: '["chat-with-content"]' # JSON array format to match the workflow input schema + + release: + runs-on: ubuntu-latest + needs: [extension, connect-integration-tests] + # Release the extension using the release-extension action + # Will only create a GitHub release if merged to `main` and the semver + # version has been updated + steps: + # Checkout the repository so the rest of the actions can run with no issue + - uses: actions/checkout@v4 + + - uses: ./.github/actions/release-extension + with: + extension-name: ${{ env.EXTENSION_NAME }} diff --git a/.github/workflows/extensions.yml b/.github/workflows/extensions.yml index 782414cc..9a24086f 100644 --- a/.github/workflows/extensions.yml +++ b/.github/workflows/extensions.yml @@ -56,7 +56,6 @@ jobs: stock-report: extensions/stock-report/** simple-mcp-server: extensions/simple-mcp-server/** simple-shiny-chat-with-mcp: extensions/simple-shiny-chat-with-mcp/** - chat-with-content: extensions/chat-with-content/** pqr: extensions/pqr/** # When infra changed, test all simple extensions; otherwise use the filter output @@ -68,6 +67,12 @@ jobs: echo "Infrastructure changed — testing all simple extensions" changes=$(find extensions -maxdepth 1 -mindepth 1 -type d -printf '%f\n' \ | while read -r ext; do + # Skip complex extensions: they have their own dedicated + # workflow (which builds them) and would fail the simple + # no-build flow (e.g. a missing frontend dist/). + if [ -f ".github/workflows/$ext.yml" ]; then + continue + fi if jq -e '.extension.minimumConnectVersion' "extensions/$ext/manifest.json" > /dev/null 2>&1; then echo "$ext" fi @@ -212,6 +217,7 @@ jobs: package-vulnerability-scanner: ${{ steps.resolve.outputs.package-vulnerability-scanner }} runtime-version-scanner: ${{ steps.resolve.outputs.runtime-version-scanner }} usage-metrics-dashboard: ${{ steps.resolve.outputs.usage-metrics-dashboard }} + chat-with-content: ${{ steps.resolve.outputs.chat-with-content }} steps: - uses: actions/checkout@v4 @@ -233,6 +239,7 @@ jobs: package-vulnerability-scanner: extensions/package-vulnerability-scanner/** runtime-version-scanner: extensions/runtime-version-scanner/** usage-metrics-dashboard: extensions/usage-metrics-dashboard/** + chat-with-content: extensions/chat-with-content/** # When infra changed, trigger all complex extensions too - id: resolve @@ -243,11 +250,13 @@ jobs: echo "package-vulnerability-scanner=true" >> $GITHUB_OUTPUT echo "runtime-version-scanner=true" >> $GITHUB_OUTPUT echo "usage-metrics-dashboard=true" >> $GITHUB_OUTPUT + echo "chat-with-content=true" >> $GITHUB_OUTPUT else echo "publisher-command-center=${{ steps.changes.outputs.publisher-command-center }}" >> $GITHUB_OUTPUT echo "package-vulnerability-scanner=${{ steps.changes.outputs.package-vulnerability-scanner }}" >> $GITHUB_OUTPUT echo "runtime-version-scanner=${{ steps.changes.outputs.runtime-version-scanner }}" >> $GITHUB_OUTPUT echo "usage-metrics-dashboard=${{ steps.changes.outputs.usage-metrics-dashboard }}" >> $GITHUB_OUTPUT + echo "chat-with-content=${{ steps.changes.outputs.chat-with-content }}" >> $GITHUB_OUTPUT fi # Creates and releases the Publisher Command Center extension using a custom @@ -287,6 +296,15 @@ jobs: uses: ./.github/workflows/usage-metrics-dashboard.yml secrets: inherit + # Creates and releases the Chat with Content extension using a custom workflow + chat-with-content: + needs: [complex-extension-changes] + # Only runs if the `complex-extension-changes` job detects changes in the + # chat-with-content extension directory + if: ${{ needs.complex-extension-changes.outputs.chat-with-content == 'true' }} + uses: ./.github/workflows/chat-with-content.yml + secrets: inherit + # All extensions have been linted, packaged, and released, if necessary # Continuing to update the extension list with the latest release data @@ -300,6 +318,7 @@ jobs: package-vulnerability-scanner, runtime-version-scanner, usage-metrics-dashboard, + chat-with-content, ] permissions: pull-requests: write @@ -383,6 +402,7 @@ jobs: package-vulnerability-scanner, runtime-version-scanner, usage-metrics-dashboard, + chat-with-content, ] if: ${{ always() }} outputs: diff --git a/extensions/chat-with-content/CHANGELOG.md b/extensions/chat-with-content/CHANGELOG.md index 507b906d..fbee739b 100644 --- a/extensions/chat-with-content/CHANGELOG.md +++ b/extensions/chat-with-content/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to the Chat with Content extension will be documented in thi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.8] - 2026-07-17 + +### Fixed + +- Guarded against content with a missing, malformed, or timezone-naive deployment + time, which could previously leave the whole content list empty. (#446) + ## [0.0.7] - 2026-06-15 ### Changed diff --git a/extensions/chat-with-content/helpers.py b/extensions/chat-with-content/helpers.py index 8462cf09..ab740299 100644 --- a/extensions/chat-with-content/helpers.py +++ b/extensions/chat-with-content/helpers.py @@ -1,31 +1,78 @@ +import os from datetime import datetime, timezone +# Static/rendered content the app can extract text from. Interactive apps (Shiny, +# Streamlit, ...) render in the browser, so their HTML holds no content to chat with. +CHATTABLE_APP_MODES = ("jupyter-static", "quarto-static", "rmd-static", "static") + +# Upper bound on the markdown handed to the LLM. A large report can produce a DOM +# far bigger than the model's context window; truncating keeps the request within +# bounds instead of erroring, at the cost of dropping the tail of very long pages. +MAX_CONTEXT_CHARS = 100_000 + + +# Both env vars are checked because a missed "on Connect" detection would fall back +# to the deploy client for a viewer (see resolve_visitor_client), so err toward True. +def running_on_connect(): + return "CONNECT" in (os.getenv("POSIT_PRODUCT"), os.getenv("RSTUDIO_PRODUCT")) -def time_since_deployment(deployment_time_str): - """ - Calculate time since deployment from ISO format datetime string. - Args: - deployment_time_str (str): Datetime string in format "2025-03-19T23:16:11Z" +# Returns (client, integration_enabled, error). The gate is the point: on Connect +# with no token (or no Visitor API Key integration, Connect error 212), keep the +# client unchanged and integration_enabled False so the caller shows the setup +# screen instead of listing the deployer's content. Off Connect, the deploy client +# is the intended one. +def resolve_visitor_client(client, on_connect, token): + if not on_connect: + return client, True, None + if not token: + return client, False, None + try: + return client.with_user_session_token(token), True, None + except Exception as err: + # Compare as a string so a code reported as 212 or "212" both count as the + # missing-integration case (setup screen) rather than a scary error screen. + if str(getattr(err, "error_code", "")) == "212": + return client, False, None + return client, True, getattr(err, "error_message", None) or str(err) - Returns: - str: Human-readable time difference like "last deployed 3 hours ago" - """ - # Parse the deployment time - deployment_time = datetime.fromisoformat(deployment_time_str.replace("Z", "+00:00")) - # Get current time in UTC +# Whether the app is fully set up and should load and use the viewer's content. +# The content selector runs as a reactive effect independent of the rendered +# screen, so it gates on this itself: a token error leaves the client as the +# unscoped deploy client (must never load with it), and a missing LLM or +# integration means the setup screen is up, so there's nothing to load for yet. +def content_ready(token_error, chat, integration_enabled): + return token_error is None and chat is not None and integration_enabled + + +def time_since_deployment(deployment_time_str): + # Content that has never been deployed reports no time; skip the label rather + # than crash on a None passed to fromisoformat(). + if not deployment_time_str: + return "" + + try: + deployment_time = datetime.fromisoformat( + deployment_time_str.replace("Z", "+00:00") + ) + except (ValueError, TypeError): + # A malformed timestamp shouldn't crash the whole content list; just omit + # the "last deployed" phrase for this one item. + return "" + # A timestamp with no offset would raise when subtracted from an aware "now"; + # treat it as UTC so a naive-but-valid time still renders instead of crashing. + if deployment_time.tzinfo is None: + deployment_time = deployment_time.replace(tzinfo=timezone.utc) current_time = datetime.now(timezone.utc) - # Calculate the difference time_diff = current_time - deployment_time total_seconds = time_diff.total_seconds() - # Handle future dates + # Deployment time slightly in the future (clock skew between servers). if total_seconds < 0: return "last deployed in the future" - # Convert to appropriate unit if total_seconds < 60: value = int(total_seconds) unit = "second" if value == 1 else "seconds" @@ -49,3 +96,33 @@ def time_since_deployment(deployment_time_str): unit = "year" if value == 1 else "years" return f"last deployed {value} {unit} ago" + + +def is_chattable_content(item): + return ( + item.app_mode in CHATTABLE_APP_MODES + and item.app_role != "none" + and item.content_category != "pin" + ) + + +def content_choice_label(item): + title = item.title or item.name or item.guid + owner = getattr(item, "owner", None) + name = "" + if owner is not None: + name = f"{owner.first_name or ''} {owner.last_name or ''}".strip() + deployed = time_since_deployment(item.last_deployed_time) + + # Join only the parts we actually have so the label never shows " - ". + suffix = " ".join(part for part in (name, deployed) if part) + return f"{title} - {suffix}" if suffix else title + + +def truncate_for_context(markdown, max_chars=MAX_CONTEXT_CHARS): + if len(markdown) <= max_chars: + return markdown + return ( + markdown[:max_chars] + + "\n\n[Content truncated because it exceeds the size this app sends to the model.]" + ) diff --git a/extensions/chat-with-content/manifest.json b/extensions/chat-with-content/manifest.json index 7e3c37a5..2cbf43b8 100644 --- a/extensions/chat-with-content/manifest.json +++ b/extensions/chat-with-content/manifest.json @@ -24,29 +24,27 @@ "description": "Provides a way to interact with and query content using an LLM chat interface.", "homepage": "https://github.com/posit-dev/connect-extensions/tree/main/extensions/chat-with-content", "category": "extension", - "tags": ["llm", "shiny", "chat", "python"], + "tags": [ + "llm", + "shiny", + "chat", + "python" + ], "minimumConnectVersion": "2025.04.0", - "requiredFeatures": ["OAuth Integrations"], + "requiredFeatures": [ + "OAuth Integrations" + ], "version": "0.0.7" }, "files": { "requirements.txt": { "checksum": "0c56ba1ce838560d153c50e71dc2b025" }, - ".gitignore": { - "checksum": "693ec79eaa892babde62587aaacf0d8b" - }, - "README.md": { - "checksum": "7b072eef923c062a0bf1667dde6c2b95" - }, "app.py": { "checksum": "74b2cad1b559b06c306a1ee44ce00185" }, "helpers.py": { - "checksum": "b18f4bc0072b6e47864670a3174b0cea" - }, - "pyproject.toml": { - "checksum": "604c28539dcb8abf952d7099ce2994bc" + "checksum": "4615f535cfe931c59e23e68b4c319f79" } } } diff --git a/extensions/chat-with-content/pyproject.toml b/extensions/chat-with-content/pyproject.toml index eee5c68c..d442146a 100644 --- a/extensions/chat-with-content/pyproject.toml +++ b/extensions/chat-with-content/pyproject.toml @@ -1,13 +1,12 @@ [project] name = "chat-with-content" version = "0.1.0" -description = "Add your description here" +description = "Shiny app that lets Connect viewers chat with their deployed content using an LLM." readme = "README.md" requires-python = ">=3.10" dependencies = [ "anthropic[bedrock]>=0.54.0", "boto3>=1.38.40", - "chatlas", "google-genai>=1.22.0", "markdownify>=1.1.0", "openai>=1.91.0", @@ -15,3 +14,9 @@ dependencies = [ "shiny>=1.4.0", "chatlas>=0.10.0", ] + +# Test-only dependencies; not bundled into the extension (requirements.txt is). +[dependency-groups] +dev = [ + "pytest>=8", +] diff --git a/extensions/chat-with-content/test_helpers.py b/extensions/chat-with-content/test_helpers.py new file mode 100644 index 00000000..d6065776 --- /dev/null +++ b/extensions/chat-with-content/test_helpers.py @@ -0,0 +1,237 @@ +# Unit tests for the pure helpers. The Shiny app (app.py) is intentionally kept +# thin over these functions so the logic can be tested without a running session +# or any LLM / Connect calls. +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import helpers + + +def iso_ago(**delta): + # ISO timestamp `delta` in the past, in the "...Z" form Connect returns. + dt = datetime.now(timezone.utc) - timedelta(**delta) + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def make_item(**overrides): + item = { + "guid": "g1", + "name": "the-name", + "title": "The Title", + "app_mode": "static", + "app_role": "owner", + "content_category": "", + "last_deployed_time": iso_ago(hours=2), + "owner": SimpleNamespace(first_name="Ada", last_name="Lovelace"), + } + item.update(overrides) + return SimpleNamespace(**item) + + +# --- running_on_connect ---------------------------------------------------- + + +def test_running_on_connect_detects_either_env_var(monkeypatch): + monkeypatch.delenv("RSTUDIO_PRODUCT", raising=False) + monkeypatch.setenv("POSIT_PRODUCT", "CONNECT") + assert helpers.running_on_connect() is True + + monkeypatch.delenv("POSIT_PRODUCT", raising=False) + monkeypatch.setenv("RSTUDIO_PRODUCT", "CONNECT") + assert helpers.running_on_connect() is True + + +def test_running_on_connect_false_off_connect(monkeypatch): + monkeypatch.delenv("POSIT_PRODUCT", raising=False) + monkeypatch.delenv("RSTUDIO_PRODUCT", raising=False) + assert helpers.running_on_connect() is False + + +# --- resolve_visitor_client ------------------------------------------------ + + +class _FakeError(Exception): + def __init__(self, error_code=None, error_message=None): + self.error_code = error_code + self.error_message = error_message + + +class _FakeClient: + def __init__(self, raises=None, scoped="scoped-client"): + self._raises = raises + self._scoped = scoped + + def with_user_session_token(self, token): + if self._raises: + raise self._raises + return self._scoped + + +def test_resolve_visitor_off_connect_uses_client_as_is(): + c = _FakeClient() + assert helpers.resolve_visitor_client(c, False, None) == (c, True, None) + + +def test_resolve_visitor_no_token_on_connect_requires_setup(): + # The key fix: no session token on Connect must NOT fall back to the deploy + # client (which would list the deployer's content); it requires setup. + c = _FakeClient() + assert helpers.resolve_visitor_client(c, True, None) == (c, False, None) + + +def test_resolve_visitor_scopes_to_the_viewer_with_a_token(): + c = _FakeClient(scoped="viewer-client") + assert helpers.resolve_visitor_client(c, True, "tok") == ( + "viewer-client", + True, + None, + ) + + +def test_resolve_visitor_missing_integration_requires_setup(): + c = _FakeClient(raises=_FakeError(error_code=212)) + assert helpers.resolve_visitor_client(c, True, "tok") == (c, False, None) + + +def test_resolve_visitor_missing_integration_string_code_requires_setup(): + # The code may arrive as a string; it must still be treated as missing-integration + # (setup screen) rather than surfaced as an error. + c = _FakeClient(raises=_FakeError(error_code="212")) + assert helpers.resolve_visitor_client(c, True, "tok") == (c, False, None) + + +def test_resolve_visitor_other_error_is_surfaced(): + c = _FakeClient(raises=_FakeError(error_code=5, error_message="permission denied")) + assert helpers.resolve_visitor_client(c, True, "tok") == ( + c, + True, + "permission denied", + ) + + +# --- time_since_deployment ------------------------------------------------- + + +def test_time_since_deployment_none_and_empty(): + assert helpers.time_since_deployment(None) == "" + assert helpers.time_since_deployment("") == "" + + +def test_time_since_deployment_malformed_returns_empty(): + # A malformed timestamp must not raise (it would otherwise crash the whole + # content list); it just omits the "last deployed" phrase. + assert helpers.time_since_deployment("not-a-date") == "" + + +def test_time_since_deployment_naive_timestamp_does_not_crash(): + # A timezone-naive but otherwise valid timestamp must be treated as UTC rather + # than raising when subtracted from an aware "now". + naive = (datetime.now(timezone.utc) - timedelta(hours=2)).strftime( + "%Y-%m-%dT%H:%M:%S" + ) + assert helpers.time_since_deployment(naive) == "last deployed 2 hours ago" + + +def test_time_since_deployment_future(): + future = (datetime.now(timezone.utc) + timedelta(hours=1)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + assert helpers.time_since_deployment(future) == "last deployed in the future" + + +def test_time_since_deployment_units_and_pluralization(): + assert helpers.time_since_deployment(iso_ago(seconds=5)) == "last deployed 5 seconds ago" + assert helpers.time_since_deployment(iso_ago(seconds=90)) == "last deployed 1 minute ago" + assert helpers.time_since_deployment(iso_ago(minutes=5)) == "last deployed 5 minutes ago" + assert helpers.time_since_deployment(iso_ago(hours=2)) == "last deployed 2 hours ago" + assert helpers.time_since_deployment(iso_ago(days=1, hours=1)) == "last deployed 1 day ago" + assert helpers.time_since_deployment(iso_ago(days=20)) == "last deployed 2 weeks ago" + assert helpers.time_since_deployment(iso_ago(days=45)) == "last deployed 1 month ago" + assert helpers.time_since_deployment(iso_ago(days=400)) == "last deployed 1 year ago" + + +# --- is_chattable_content -------------------------------------------------- + + +def test_is_chattable_content_accepts_static_content(): + assert helpers.is_chattable_content(make_item()) is True + assert helpers.is_chattable_content(make_item(app_mode="quarto-static")) is True + + +def test_is_chattable_content_rejects_interactive_apps(): + assert helpers.is_chattable_content(make_item(app_mode="python-shiny")) is False + + +def test_is_chattable_content_rejects_unpublished_and_pins(): + assert helpers.is_chattable_content(make_item(app_role="none")) is False + assert helpers.is_chattable_content(make_item(content_category="pin")) is False + + +# --- content_choice_label -------------------------------------------------- + + +def test_content_choice_label_full(): + label = helpers.content_choice_label(make_item()) + assert label == "The Title - Ada Lovelace last deployed 2 hours ago" + + +def test_content_choice_label_falls_back_to_name_then_guid(): + assert helpers.content_choice_label(make_item(title=None)).startswith("the-name") + assert helpers.content_choice_label( + make_item(title=None, name=None) + ).startswith("g1") + + +def test_content_choice_label_tolerates_missing_owner_and_date(): + label = helpers.content_choice_label( + make_item(owner=None, last_deployed_time=None) + ) + # No owner and no deploy time -> just the title, no dangling " - ". + assert label == "The Title" + + +def test_content_choice_label_handles_blank_owner_names(): + label = helpers.content_choice_label( + make_item( + owner=SimpleNamespace(first_name=None, last_name=None), + last_deployed_time=None, + ) + ) + assert label == "The Title" + + +# --- truncate_for_context -------------------------------------------------- + + +def test_truncate_for_context_leaves_short_content_untouched(): + text = "a" * 100 + assert helpers.truncate_for_context(text, max_chars=1000) == text + + +def test_truncate_for_context_caps_long_content(): + text = "a" * 5000 + result = helpers.truncate_for_context(text, max_chars=1000) + assert result.startswith("a" * 1000) + assert "truncated" in result + assert len(result) < len(text) + + +# --- content_ready --------------------------------------------------------- + + +def test_content_ready_true_when_session_llm_and_integration_all_present(): + assert helpers.content_ready(None, object(), True) is True + + +def test_content_ready_false_on_token_error(): + # A token error leaves the client as the unscoped deploy client, so content + # must not load even though the integration flag is True. + assert helpers.content_ready("exchange failed", object(), True) is False + + +def test_content_ready_false_without_llm(): + assert helpers.content_ready(None, None, True) is False + + +def test_content_ready_false_when_integration_disabled(): + assert helpers.content_ready(None, object(), False) is False From 122e231cd1e5482868b37add1ed244ed74851a4f Mon Sep 17 00:00:00 2001 From: Amy Lin Date: Tue, 28 Jul 2026 10:18:44 -0500 Subject: [PATCH 2/9] fix(chat-with-content): read content fields by key, pin CI to Python 3.12 --- .github/workflows/chat-with-content.yml | 6 +- extensions/chat-with-content/helpers.py | 27 ++- extensions/chat-with-content/manifest.json | 2 +- extensions/chat-with-content/test_helpers.py | 202 +++++++++++++++---- 4 files changed, 182 insertions(+), 55 deletions(-) diff --git a/.github/workflows/chat-with-content.yml b/.github/workflows/chat-with-content.yml index 158e1506..25c6315c 100644 --- a/.github/workflows/chat-with-content.yml +++ b/.github/workflows/chat-with-content.yml @@ -25,10 +25,14 @@ jobs: with: extension-name: ${{ env.EXTENSION_NAME }} - # Run the Python tests before packaging. + # Run the Python tests before packaging. pyproject-file only picks the uv + # version, so pin the interpreter separately: without it uv resolves the + # newest Python satisfying requires-python, and the tests would stop matching + # the version Connect deploys on (manifest.json's python.version). - uses: astral-sh/setup-uv@v5 with: pyproject-file: ./extensions/${{ env.EXTENSION_NAME }}/pyproject.toml + python-version: "3.12" - name: Run tests run: uv run pytest diff --git a/extensions/chat-with-content/helpers.py b/extensions/chat-with-content/helpers.py index ab740299..6765d582 100644 --- a/extensions/chat-with-content/helpers.py +++ b/extensions/chat-with-content/helpers.py @@ -56,9 +56,10 @@ def time_since_deployment(deployment_time_str): deployment_time = datetime.fromisoformat( deployment_time_str.replace("Z", "+00:00") ) - except (ValueError, TypeError): + except (AttributeError, TypeError, ValueError): # A malformed timestamp shouldn't crash the whole content list; just omit - # the "last deployed" phrase for this one item. + # the "last deployed" phrase for this one item. AttributeError covers a + # value that isn't a string at all, which has no .replace(). return "" # A timestamp with no offset would raise when subtracted from an aware "now"; # treat it as UTC so a naive-but-valid time still renders instead of crashing. @@ -98,21 +99,25 @@ def time_since_deployment(deployment_time_str): return f"last deployed {value} {unit} ago" +# Content items are read with .get() throughout: Connect leaves optional fields out +# of the payload, and the SDK raises AttributeError for a field accessed as an +# attribute but absent, which would take out the whole content list over one unusual +# item. Attribute access is also deprecated in the SDK in favour of key access. def is_chattable_content(item): return ( - item.app_mode in CHATTABLE_APP_MODES - and item.app_role != "none" - and item.content_category != "pin" + item.get("app_mode") in CHATTABLE_APP_MODES + and item.get("app_role") != "none" + and item.get("content_category") != "pin" ) def content_choice_label(item): - title = item.title or item.name or item.guid - owner = getattr(item, "owner", None) - name = "" - if owner is not None: - name = f"{owner.first_name or ''} {owner.last_name or ''}".strip() - deployed = time_since_deployment(item.last_deployed_time) + title = item.get("title") or item.get("name") or item.get("guid") + # .get() also sidesteps ContentItem.owner, a property that fetches the owner + # over HTTP (one request per item) when Connect didn't include it. + owner = item.get("owner") or {} + name = f"{owner.get('first_name') or ''} {owner.get('last_name') or ''}".strip() + deployed = time_since_deployment(item.get("last_deployed_time")) # Join only the parts we actually have so the label never shows " - ". suffix = " ".join(part for part in (name, deployed) if part) diff --git a/extensions/chat-with-content/manifest.json b/extensions/chat-with-content/manifest.json index 2cbf43b8..d9760779 100644 --- a/extensions/chat-with-content/manifest.json +++ b/extensions/chat-with-content/manifest.json @@ -44,7 +44,7 @@ "checksum": "74b2cad1b559b06c306a1ee44ce00185" }, "helpers.py": { - "checksum": "4615f535cfe931c59e23e68b4c319f79" + "checksum": "ef1a992708c3c843a38b300fea287fe8" } } } diff --git a/extensions/chat-with-content/test_helpers.py b/extensions/chat-with-content/test_helpers.py index d6065776..8799faf0 100644 --- a/extensions/chat-with-content/test_helpers.py +++ b/extensions/chat-with-content/test_helpers.py @@ -1,31 +1,57 @@ -# Unit tests for the pure helpers. The Shiny app (app.py) is intentionally kept -# thin over these functions so the logic can be tested without a running session -# or any LLM / Connect calls. +# Unit tests for the pure helpers. Logic goes in helpers.py when it can be tested +# without a running Shiny session or any LLM / Connect calls; what stays in app.py +# needs a live session, so it is checked by running the app. from datetime import datetime, timedelta, timezone -from types import SimpleNamespace + +import pytest +from posit.connect.content import ContentItem import helpers +# "now" is frozen for the time tests so a bucket boundary can be asserted exactly +# and a slow test run can't tip "5 seconds" over to "6 seconds". +FROZEN_NOW = datetime(2026, 7, 28, 12, 0, 0, tzinfo=timezone.utc) + + +@pytest.fixture +def frozen_now(monkeypatch): + class _FrozenDatetime(datetime): + @classmethod + def now(cls, tz=None): + return FROZEN_NOW + + monkeypatch.setattr(helpers, "datetime", _FrozenDatetime) + -def iso_ago(**delta): - # ISO timestamp `delta` in the past, in the "...Z" form Connect returns. - dt = datetime.now(timezone.utc) - timedelta(**delta) - return dt.strftime("%Y-%m-%dT%H:%M:%SZ") +def ago(**delta): + # ISO timestamp `delta` before FROZEN_NOW, in the "...Z" form Connect returns. + return (FROZEN_NOW - timedelta(**delta)).strftime("%Y-%m-%dT%H:%M:%SZ") + + +class _FakeContext(dict): + # ContentItem wants a context to make further requests with. The helpers never + # trigger one, so an empty stand-in is enough. + pass def make_item(**overrides): - item = { + # A real ContentItem, not a look-alike. The SDK raises on an absent field and + # exposes `owner` as a property that fetches over HTTP, so a hand-rolled fake + # can pass while the real object fails. + fields = { "guid": "g1", "name": "the-name", "title": "The Title", "app_mode": "static", "app_role": "owner", "content_category": "", - "last_deployed_time": iso_ago(hours=2), - "owner": SimpleNamespace(first_name="Ada", last_name="Lovelace"), + "last_deployed_time": ago(hours=2), + "owner": {"first_name": "Ada", "last_name": "Lovelace"}, } - item.update(overrides) - return SimpleNamespace(**item) + fields.update(overrides) + # None means "Connect left this field out", so drop the key entirely rather + # than sending a None the real payload would never contain. + return ContentItem(_FakeContext(), **{k: v for k, v in fields.items() if v is not None}) # --- running_on_connect ---------------------------------------------------- @@ -47,6 +73,12 @@ def test_running_on_connect_false_off_connect(monkeypatch): assert helpers.running_on_connect() is False +def test_running_on_connect_false_on_another_posit_product(monkeypatch): + monkeypatch.delenv("RSTUDIO_PRODUCT", raising=False) + monkeypatch.setenv("POSIT_PRODUCT", "WORKBENCH") + assert helpers.running_on_connect() is False + + # --- resolve_visitor_client ------------------------------------------------ @@ -109,6 +141,17 @@ def test_resolve_visitor_other_error_is_surfaced(): ) +def test_resolve_visitor_error_without_a_message_still_says_something(): + # Not every failure is a ClientError with error_message; the viewer must still + # be told why rather than getting an empty error screen. + c = _FakeClient(raises=RuntimeError("connection refused")) + assert helpers.resolve_visitor_client(c, True, "tok") == ( + c, + True, + "connection refused", + ) + + # --- time_since_deployment ------------------------------------------------- @@ -123,39 +166,78 @@ def test_time_since_deployment_malformed_returns_empty(): assert helpers.time_since_deployment("not-a-date") == "" -def test_time_since_deployment_naive_timestamp_does_not_crash(): +def test_time_since_deployment_non_string_returns_empty(): + # Anything that isn't a string has no .replace(); it must degrade, not raise. + assert helpers.time_since_deployment(1750000000) == "" + assert helpers.time_since_deployment(["2026-01-01"]) == "" + + +def test_time_since_deployment_naive_timestamp_does_not_crash(frozen_now): # A timezone-naive but otherwise valid timestamp must be treated as UTC rather # than raising when subtracted from an aware "now". - naive = (datetime.now(timezone.utc) - timedelta(hours=2)).strftime( - "%Y-%m-%dT%H:%M:%S" - ) + naive = (FROZEN_NOW - timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%S") assert helpers.time_since_deployment(naive) == "last deployed 2 hours ago" -def test_time_since_deployment_future(): - future = (datetime.now(timezone.utc) + timedelta(hours=1)).strftime( - "%Y-%m-%dT%H:%M:%SZ" +def test_time_since_deployment_future(frozen_now): + assert ( + helpers.time_since_deployment(ago(hours=-1)) + == "last deployed in the future" ) - assert helpers.time_since_deployment(future) == "last deployed in the future" -def test_time_since_deployment_units_and_pluralization(): - assert helpers.time_since_deployment(iso_ago(seconds=5)) == "last deployed 5 seconds ago" - assert helpers.time_since_deployment(iso_ago(seconds=90)) == "last deployed 1 minute ago" - assert helpers.time_since_deployment(iso_ago(minutes=5)) == "last deployed 5 minutes ago" - assert helpers.time_since_deployment(iso_ago(hours=2)) == "last deployed 2 hours ago" - assert helpers.time_since_deployment(iso_ago(days=1, hours=1)) == "last deployed 1 day ago" - assert helpers.time_since_deployment(iso_ago(days=20)) == "last deployed 2 weeks ago" - assert helpers.time_since_deployment(iso_ago(days=45)) == "last deployed 1 month ago" - assert helpers.time_since_deployment(iso_ago(days=400)) == "last deployed 1 year ago" +def test_time_since_deployment_plural_units(frozen_now): + assert helpers.time_since_deployment(ago(seconds=5)) == "last deployed 5 seconds ago" + assert helpers.time_since_deployment(ago(minutes=5)) == "last deployed 5 minutes ago" + assert helpers.time_since_deployment(ago(hours=2)) == "last deployed 2 hours ago" + assert helpers.time_since_deployment(ago(days=3)) == "last deployed 3 days ago" + assert helpers.time_since_deployment(ago(days=20)) == "last deployed 2 weeks ago" + assert helpers.time_since_deployment(ago(days=95)) == "last deployed 3 months ago" + assert helpers.time_since_deployment(ago(days=800)) == "last deployed 2 years ago" + + +def test_time_since_deployment_singular_units(frozen_now): + # Every unit has its own singular form, so every unit needs pinning. + assert helpers.time_since_deployment(ago(seconds=1)) == "last deployed 1 second ago" + assert helpers.time_since_deployment(ago(seconds=90)) == "last deployed 1 minute ago" + assert helpers.time_since_deployment(ago(hours=1)) == "last deployed 1 hour ago" + assert helpers.time_since_deployment(ago(days=1)) == "last deployed 1 day ago" + assert helpers.time_since_deployment(ago(days=7)) == "last deployed 1 week ago" + assert helpers.time_since_deployment(ago(days=45)) == "last deployed 1 month ago" + assert helpers.time_since_deployment(ago(days=400)) == "last deployed 1 year ago" + + +def test_time_since_deployment_unit_boundaries(frozen_now): + # Each threshold rolls over to the next unit exactly once, so an off-by-one in + # any boundary shows up here. + assert helpers.time_since_deployment(ago(seconds=59)) == "last deployed 59 seconds ago" + assert helpers.time_since_deployment(ago(seconds=60)) == "last deployed 1 minute ago" + assert helpers.time_since_deployment(ago(minutes=59)) == "last deployed 59 minutes ago" + assert helpers.time_since_deployment(ago(minutes=60)) == "last deployed 1 hour ago" + assert helpers.time_since_deployment(ago(hours=23)) == "last deployed 23 hours ago" + assert helpers.time_since_deployment(ago(hours=24)) == "last deployed 1 day ago" + assert helpers.time_since_deployment(ago(days=6)) == "last deployed 6 days ago" + assert helpers.time_since_deployment(ago(seconds=0)) == "last deployed 0 seconds ago" # --- is_chattable_content -------------------------------------------------- -def test_is_chattable_content_accepts_static_content(): - assert helpers.is_chattable_content(make_item()) is True - assert helpers.is_chattable_content(make_item(app_mode="quarto-static")) is True +def test_chattable_modes_are_the_four_static_modes(): + # Pin the list itself: dropping a mode would silently hide that content type. + assert set(helpers.CHATTABLE_APP_MODES) == { + "jupyter-static", + "quarto-static", + "rmd-static", + "static", + } + + +@pytest.mark.parametrize( + "mode", ["static", "quarto-static", "rmd-static", "jupyter-static"] +) +def test_is_chattable_content_accepts_every_static_mode(mode): + assert helpers.is_chattable_content(make_item(app_mode=mode)) is True def test_is_chattable_content_rejects_interactive_apps(): @@ -170,16 +252,16 @@ def test_is_chattable_content_rejects_unpublished_and_pins(): # --- content_choice_label -------------------------------------------------- -def test_content_choice_label_full(): +def test_content_choice_label_full(frozen_now): label = helpers.content_choice_label(make_item()) assert label == "The Title - Ada Lovelace last deployed 2 hours ago" def test_content_choice_label_falls_back_to_name_then_guid(): assert helpers.content_choice_label(make_item(title=None)).startswith("the-name") - assert helpers.content_choice_label( - make_item(title=None, name=None) - ).startswith("g1") + assert helpers.content_choice_label(make_item(title=None, name=None)).startswith( + "g1" + ) def test_content_choice_label_tolerates_missing_owner_and_date(): @@ -193,13 +275,33 @@ def test_content_choice_label_tolerates_missing_owner_and_date(): def test_content_choice_label_handles_blank_owner_names(): label = helpers.content_choice_label( make_item( - owner=SimpleNamespace(first_name=None, last_name=None), + owner={"first_name": None, "last_name": None}, last_deployed_time=None, ) ) assert label == "The Title" +def test_content_choice_label_with_owner_but_no_date(): + assert helpers.content_choice_label( + make_item(last_deployed_time=None) + ) == "The Title - Ada Lovelace" + + +def test_content_choice_label_with_date_but_no_owner(frozen_now): + assert helpers.content_choice_label(make_item(owner=None)) == ( + "The Title - last deployed 2 hours ago" + ) + + +def test_helpers_tolerate_content_missing_every_optional_field(): + # Connect omits optional fields, and the SDK raises when one is read as an + # attribute. One unusual item must not be able to empty the whole selector. + bare = ContentItem(_FakeContext(), guid="g9") + assert helpers.is_chattable_content(bare) is False + assert helpers.content_choice_label(bare) == "g9" + + # --- truncate_for_context -------------------------------------------------- @@ -208,12 +310,28 @@ def test_truncate_for_context_leaves_short_content_untouched(): assert helpers.truncate_for_context(text, max_chars=1000) == text -def test_truncate_for_context_caps_long_content(): - text = "a" * 5000 - result = helpers.truncate_for_context(text, max_chars=1000) +def test_truncate_for_context_keeps_exactly_the_limit(): + result = helpers.truncate_for_context("a" * 5000, max_chars=1000) assert result.startswith("a" * 1000) + # Exactly max_chars of content, not merely "fewer than we started with". + assert not result.startswith("a" * 1001) assert "truncated" in result - assert len(result) < len(text) + + +def test_context_limit_is_the_documented_100k(): + # The README tells people to tune this, so the shipped value is pinned. + assert helpers.MAX_CONTEXT_CHARS == 100_000 + + +def test_truncate_for_context_applies_the_limit_by_default(): + # The default is what production uses; the explicit-max_chars tests above + # would pass even if it were wrong. + limit = helpers.MAX_CONTEXT_CHARS + assert helpers.truncate_for_context("a" * limit) == "a" * limit + over = helpers.truncate_for_context("a" * (limit + 1)) + assert over.startswith("a" * limit) + assert not over.startswith("a" * (limit + 1)) + assert "truncated" in over # --- content_ready --------------------------------------------------------- From 3b920b2af637c97d45dbad0b0c4f794de2c92c69 Mon Sep 17 00:00:00 2001 From: Amy Lin Date: Tue, 28 Jul 2026 10:30:07 -0500 Subject: [PATCH 3/9] fix(chat-with-content): tell a missing session apart from a missing integration --- extensions/chat-with-content/helpers.py | 32 +++++++++++++---- extensions/chat-with-content/manifest.json | 2 +- extensions/chat-with-content/test_helpers.py | 36 ++++++++++++++++---- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/extensions/chat-with-content/helpers.py b/extensions/chat-with-content/helpers.py index 6765d582..a117c06a 100644 --- a/extensions/chat-with-content/helpers.py +++ b/extensions/chat-with-content/helpers.py @@ -17,16 +17,33 @@ def running_on_connect(): return "CONNECT" in (os.getenv("POSIT_PRODUCT"), os.getenv("RSTUDIO_PRODUCT")) -# Returns (client, integration_enabled, error). The gate is the point: on Connect -# with no token (or no Visitor API Key integration, Connect error 212), keep the -# client unchanged and integration_enabled False so the caller shows the setup -# screen instead of listing the deployer's content. Off Connect, the deploy client -# is the intended one. +# What the viewer is told when their session can't be used at all. Adding the +# integration fixes neither case, so these are kept separate from the setup screen. +NO_SESSION_DETAIL = ( + "Couldn't read your Connect session, so the app can't list or read content as " + "you. Make sure you're signed in to Connect. If you are, your administrator may " + "need to enable OAuth integrations on this server." +) +EXCHANGE_FAILED_DETAIL = ( + "Couldn't read your Connect session, so the app can't list or read content as " + "you. The error was:" +) + + +# Returns (client, integration_enabled, session_error), where session_error is a +# (detail, raw_error) pair when the viewer's session can't be used and raw_error is +# None if there is no underlying exception worth showing. The gate is the point: +# never fall back to the deploy client for a viewer, because that would list the +# deployer's content as if it were theirs. Off Connect, the deploy client is the +# intended one. def resolve_visitor_client(client, on_connect, token): if not on_connect: return client, True, None if not token: - return client, False, None + # No token means there is no signed-in viewer to act as: content that allows + # anonymous access sends none, and OAuth integrations may be off server-wide. + # Neither is fixed on the Access tab, so say that rather than showing setup. + return client, True, (NO_SESSION_DETAIL, None) try: return client.with_user_session_token(token), True, None except Exception as err: @@ -34,7 +51,8 @@ def resolve_visitor_client(client, on_connect, token): # missing-integration case (setup screen) rather than a scary error screen. if str(getattr(err, "error_code", "")) == "212": return client, False, None - return client, True, getattr(err, "error_message", None) or str(err) + raw = getattr(err, "error_message", None) or str(err) + return client, True, (EXCHANGE_FAILED_DETAIL, raw) # Whether the app is fully set up and should load and use the viewer's content. diff --git a/extensions/chat-with-content/manifest.json b/extensions/chat-with-content/manifest.json index d9760779..fa8c1fc6 100644 --- a/extensions/chat-with-content/manifest.json +++ b/extensions/chat-with-content/manifest.json @@ -44,7 +44,7 @@ "checksum": "74b2cad1b559b06c306a1ee44ce00185" }, "helpers.py": { - "checksum": "ef1a992708c3c843a38b300fea287fe8" + "checksum": "a8ba29800a562ad25140f7c87c6b21ee" } } } diff --git a/extensions/chat-with-content/test_helpers.py b/extensions/chat-with-content/test_helpers.py index 8799faf0..9399add1 100644 --- a/extensions/chat-with-content/test_helpers.py +++ b/extensions/chat-with-content/test_helpers.py @@ -104,11 +104,22 @@ def test_resolve_visitor_off_connect_uses_client_as_is(): assert helpers.resolve_visitor_client(c, False, None) == (c, True, None) -def test_resolve_visitor_no_token_on_connect_requires_setup(): - # The key fix: no session token on Connect must NOT fall back to the deploy - # client (which would list the deployer's content); it requires setup. - c = _FakeClient() - assert helpers.resolve_visitor_client(c, True, None) == (c, False, None) +def test_resolve_visitor_no_token_on_connect_never_uses_the_deploy_client(): + # No session token on Connect must NOT fall back to the deploy client, which + # would list the deployer's content as if it were the viewer's. + client, integration_enabled, session_error = helpers.resolve_visitor_client( + _FakeClient(), True, None + ) + assert session_error is not None + detail, raw = session_error + assert raw is None # nothing technical to show; the cause is the access setup + # It must NOT be reported as a missing integration: neither being signed out nor + # server-wide OAuth being off is fixed by adding one, so the setup screen (which + # integration_enabled=False would trigger) would be unactionable. + assert integration_enabled is True + assert "signed in" in detail + assert "OAuth integrations" in detail + assert "Visitor API Key" not in detail def test_resolve_visitor_scopes_to_the_viewer_with_a_token(): @@ -137,7 +148,7 @@ def test_resolve_visitor_other_error_is_surfaced(): assert helpers.resolve_visitor_client(c, True, "tok") == ( c, True, - "permission denied", + (helpers.EXCHANGE_FAILED_DETAIL, "permission denied"), ) @@ -148,8 +159,19 @@ def test_resolve_visitor_error_without_a_message_still_says_something(): assert helpers.resolve_visitor_client(c, True, "tok") == ( c, True, - "connection refused", + (helpers.EXCHANGE_FAILED_DETAIL, "connection refused"), + ) + + +def test_the_two_session_failures_are_told_apart(): + # A missing integration is actionable on the Access tab, so it keeps the setup + # screen; a missing session is not, so it must not be reported the same way. + missing_integration = helpers.resolve_visitor_client( + _FakeClient(raises=_FakeError(error_code=212)), True, "tok" ) + no_session = helpers.resolve_visitor_client(_FakeClient(), True, None) + assert missing_integration[1] is False and missing_integration[2] is None + assert no_session[1] is True and no_session[2] is not None # --- time_since_deployment ------------------------------------------------- From da505512d4f53a6766c883b69fed6ff81d50b3e8 Mon Sep 17 00:00:00 2001 From: Amy Lin Date: Tue, 28 Jul 2026 11:14:58 -0500 Subject: [PATCH 4/9] fix(chat-with-content): keep the truncation notice out of an open code fence --- extensions/chat-with-content/helpers.py | 7 ++++++- extensions/chat-with-content/manifest.json | 2 +- extensions/chat-with-content/pyproject.toml | 4 +++- extensions/chat-with-content/test_helpers.py | 13 +++++++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/extensions/chat-with-content/helpers.py b/extensions/chat-with-content/helpers.py index a117c06a..39c01168 100644 --- a/extensions/chat-with-content/helpers.py +++ b/extensions/chat-with-content/helpers.py @@ -145,7 +145,12 @@ def content_choice_label(item): def truncate_for_context(markdown, max_chars=MAX_CONTEXT_CHARS): if len(markdown) <= max_chars: return markdown + kept = markdown[:max_chars] + # Cutting mid-page can leave a code fence open, which would make the model read + # the notice below as more code instead of as a note about the content. + if kept.count("```") % 2: + kept += "\n```" return ( - markdown[:max_chars] + kept + "\n\n[Content truncated because it exceeds the size this app sends to the model.]" ) diff --git a/extensions/chat-with-content/manifest.json b/extensions/chat-with-content/manifest.json index fa8c1fc6..65570d05 100644 --- a/extensions/chat-with-content/manifest.json +++ b/extensions/chat-with-content/manifest.json @@ -44,7 +44,7 @@ "checksum": "74b2cad1b559b06c306a1ee44ce00185" }, "helpers.py": { - "checksum": "a8ba29800a562ad25140f7c87c6b21ee" + "checksum": "8c7d9fef92f3feb69412f9cb1c50624b" } } } diff --git a/extensions/chat-with-content/pyproject.toml b/extensions/chat-with-content/pyproject.toml index d442146a..a5858dd8 100644 --- a/extensions/chat-with-content/pyproject.toml +++ b/extensions/chat-with-content/pyproject.toml @@ -10,7 +10,9 @@ dependencies = [ "google-genai>=1.22.0", "markdownify>=1.1.0", "openai>=1.91.0", - "posit-sdk>=0.10.0", + # Upper bound matches requirements.txt: posit-sdk 1.0.0 removes attribute access + # on API objects, which this app relies on the dict form of. + "posit-sdk>=0.10.0,<1.0.0", "shiny>=1.4.0", "chatlas>=0.10.0", ] diff --git a/extensions/chat-with-content/test_helpers.py b/extensions/chat-with-content/test_helpers.py index 9399add1..08acd4b6 100644 --- a/extensions/chat-with-content/test_helpers.py +++ b/extensions/chat-with-content/test_helpers.py @@ -340,6 +340,19 @@ def test_truncate_for_context_keeps_exactly_the_limit(): assert "truncated" in result +def test_truncate_for_context_closes_a_code_fence_it_cut_open(): + # Cutting inside a fenced block would leave the notice below inside the block, + # where the model reads it as more code rather than as a note about the content. + result = helpers.truncate_for_context("```python\n" + "x = 1\n" * 500, max_chars=50) + assert result.count("```") % 2 == 0 + assert "\n```\n\n[Content truncated" in result + + +def test_truncate_for_context_leaves_balanced_fences_alone(): + result = helpers.truncate_for_context("```\ncode\n```\n" + "a" * 5000, max_chars=1000) + assert result.count("```") == 2 + + def test_context_limit_is_the_documented_100k(): # The README tells people to tune this, so the shipped value is pinned. assert helpers.MAX_CONTEXT_CHARS == 100_000 From 1d52fffe68f7aae86a10ce278840444a63b79d5b Mon Sep 17 00:00:00 2001 From: Amy Lin Date: Tue, 28 Jul 2026 11:19:05 -0500 Subject: [PATCH 5/9] docs(chat-with-content): note the truncation code-fence fix --- extensions/chat-with-content/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/chat-with-content/CHANGELOG.md b/extensions/chat-with-content/CHANGELOG.md index fbee739b..3cc36f7a 100644 --- a/extensions/chat-with-content/CHANGELOG.md +++ b/extensions/chat-with-content/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Guarded against content with a missing, malformed, or timezone-naive deployment time, which could previously leave the whole content list empty. (#446) +- Close a code block that truncation cut open, so the model reads the truncation + note as a note rather than as more code. (#446) ## [0.0.7] - 2026-06-15 From 1d4411712de843c82f8218ca40e70105d29fbc51 Mon Sep 17 00:00:00 2001 From: Amy Lin Date: Tue, 28 Jul 2026 11:47:16 -0500 Subject: [PATCH 6/9] fix(chat-with-content): count only line-start code fences when truncating --- extensions/chat-with-content/helpers.py | 8 ++- extensions/chat-with-content/manifest.json | 2 +- extensions/chat-with-content/test_helpers.py | 67 ++++++++++++++------ 3 files changed, 55 insertions(+), 22 deletions(-) diff --git a/extensions/chat-with-content/helpers.py b/extensions/chat-with-content/helpers.py index 39c01168..c18436ce 100644 --- a/extensions/chat-with-content/helpers.py +++ b/extensions/chat-with-content/helpers.py @@ -147,8 +147,12 @@ def truncate_for_context(markdown, max_chars=MAX_CONTEXT_CHARS): return markdown kept = markdown[:max_chars] # Cutting mid-page can leave a code fence open, which would make the model read - # the notice below as more code instead of as a note about the content. - if kept.count("```") % 2: + # the notice below as more code instead of as a note about the content. Only a + # fence starting a line opens or closes a block: page content that merely + # mentions ``` mid-line must not be counted, or a balanced block would be + # "closed" again and the notice pushed inside the new one. + fences = sum(1 for line in kept.splitlines() if line.lstrip().startswith("```")) + if fences % 2: kept += "\n```" return ( kept diff --git a/extensions/chat-with-content/manifest.json b/extensions/chat-with-content/manifest.json index 65570d05..cdfed4d1 100644 --- a/extensions/chat-with-content/manifest.json +++ b/extensions/chat-with-content/manifest.json @@ -44,7 +44,7 @@ "checksum": "74b2cad1b559b06c306a1ee44ce00185" }, "helpers.py": { - "checksum": "8c7d9fef92f3feb69412f9cb1c50624b" + "checksum": "e0a4882026e3fef601e04469ddc11a01" } } } diff --git a/extensions/chat-with-content/test_helpers.py b/extensions/chat-with-content/test_helpers.py index 08acd4b6..33ac00a4 100644 --- a/extensions/chat-with-content/test_helpers.py +++ b/extensions/chat-with-content/test_helpers.py @@ -51,7 +51,9 @@ def make_item(**overrides): fields.update(overrides) # None means "Connect left this field out", so drop the key entirely rather # than sending a None the real payload would never contain. - return ContentItem(_FakeContext(), **{k: v for k, v in fields.items() if v is not None}) + return ContentItem( + _FakeContext(), **{k: v for k, v in fields.items() if v is not None} + ) # --- running_on_connect ---------------------------------------------------- @@ -202,15 +204,16 @@ def test_time_since_deployment_naive_timestamp_does_not_crash(frozen_now): def test_time_since_deployment_future(frozen_now): - assert ( - helpers.time_since_deployment(ago(hours=-1)) - == "last deployed in the future" - ) + assert helpers.time_since_deployment(ago(hours=-1)) == "last deployed in the future" def test_time_since_deployment_plural_units(frozen_now): - assert helpers.time_since_deployment(ago(seconds=5)) == "last deployed 5 seconds ago" - assert helpers.time_since_deployment(ago(minutes=5)) == "last deployed 5 minutes ago" + assert ( + helpers.time_since_deployment(ago(seconds=5)) == "last deployed 5 seconds ago" + ) + assert ( + helpers.time_since_deployment(ago(minutes=5)) == "last deployed 5 minutes ago" + ) assert helpers.time_since_deployment(ago(hours=2)) == "last deployed 2 hours ago" assert helpers.time_since_deployment(ago(days=3)) == "last deployed 3 days ago" assert helpers.time_since_deployment(ago(days=20)) == "last deployed 2 weeks ago" @@ -221,7 +224,9 @@ def test_time_since_deployment_plural_units(frozen_now): def test_time_since_deployment_singular_units(frozen_now): # Every unit has its own singular form, so every unit needs pinning. assert helpers.time_since_deployment(ago(seconds=1)) == "last deployed 1 second ago" - assert helpers.time_since_deployment(ago(seconds=90)) == "last deployed 1 minute ago" + assert ( + helpers.time_since_deployment(ago(seconds=90)) == "last deployed 1 minute ago" + ) assert helpers.time_since_deployment(ago(hours=1)) == "last deployed 1 hour ago" assert helpers.time_since_deployment(ago(days=1)) == "last deployed 1 day ago" assert helpers.time_since_deployment(ago(days=7)) == "last deployed 1 week ago" @@ -232,14 +237,22 @@ def test_time_since_deployment_singular_units(frozen_now): def test_time_since_deployment_unit_boundaries(frozen_now): # Each threshold rolls over to the next unit exactly once, so an off-by-one in # any boundary shows up here. - assert helpers.time_since_deployment(ago(seconds=59)) == "last deployed 59 seconds ago" - assert helpers.time_since_deployment(ago(seconds=60)) == "last deployed 1 minute ago" - assert helpers.time_since_deployment(ago(minutes=59)) == "last deployed 59 minutes ago" + assert ( + helpers.time_since_deployment(ago(seconds=59)) == "last deployed 59 seconds ago" + ) + assert ( + helpers.time_since_deployment(ago(seconds=60)) == "last deployed 1 minute ago" + ) + assert ( + helpers.time_since_deployment(ago(minutes=59)) == "last deployed 59 minutes ago" + ) assert helpers.time_since_deployment(ago(minutes=60)) == "last deployed 1 hour ago" assert helpers.time_since_deployment(ago(hours=23)) == "last deployed 23 hours ago" assert helpers.time_since_deployment(ago(hours=24)) == "last deployed 1 day ago" assert helpers.time_since_deployment(ago(days=6)) == "last deployed 6 days ago" - assert helpers.time_since_deployment(ago(seconds=0)) == "last deployed 0 seconds ago" + assert ( + helpers.time_since_deployment(ago(seconds=0)) == "last deployed 0 seconds ago" + ) # --- is_chattable_content -------------------------------------------------- @@ -287,9 +300,7 @@ def test_content_choice_label_falls_back_to_name_then_guid(): def test_content_choice_label_tolerates_missing_owner_and_date(): - label = helpers.content_choice_label( - make_item(owner=None, last_deployed_time=None) - ) + label = helpers.content_choice_label(make_item(owner=None, last_deployed_time=None)) # No owner and no deploy time -> just the title, no dangling " - ". assert label == "The Title" @@ -305,9 +316,10 @@ def test_content_choice_label_handles_blank_owner_names(): def test_content_choice_label_with_owner_but_no_date(): - assert helpers.content_choice_label( - make_item(last_deployed_time=None) - ) == "The Title - Ada Lovelace" + assert ( + helpers.content_choice_label(make_item(last_deployed_time=None)) + == "The Title - Ada Lovelace" + ) def test_content_choice_label_with_date_but_no_owner(frozen_now): @@ -349,10 +361,27 @@ def test_truncate_for_context_closes_a_code_fence_it_cut_open(): def test_truncate_for_context_leaves_balanced_fences_alone(): - result = helpers.truncate_for_context("```\ncode\n```\n" + "a" * 5000, max_chars=1000) + result = helpers.truncate_for_context( + "```\ncode\n```\n" + "a" * 5000, max_chars=1000 + ) assert result.count("```") == 2 +def test_truncate_for_context_ignores_a_fence_mentioned_mid_line(): + # markdownify copies
 bodies verbatim, so a page that documents markdown can
+    # contain ``` inside an already-closed block. Counting those would "close" the
+    # block a second time and push the notice inside the new one.
+    body = "x" * 40 + "\n```text\nTo make a code block write ```\nlike that\n```\n"
+    result = helpers.truncate_for_context(body + "y" * 200, max_chars=len(body))
+    assert result.endswith("]")
+    # The notice must sit outside any block: an even number of fence lines precede it.
+    before_notice = result.split("\n\n[Content truncated")[0]
+    fence_lines = sum(
+        1 for line in before_notice.splitlines() if line.lstrip().startswith("```")
+    )
+    assert fence_lines % 2 == 0
+
+
 def test_context_limit_is_the_documented_100k():
     # The README tells people to tune this, so the shipped value is pinned.
     assert helpers.MAX_CONTEXT_CHARS == 100_000

From 89d1cc53cfc63fe4e0846d43056184534659a081 Mon Sep 17 00:00:00 2001
From: Amy Lin 
Date: Wed, 29 Jul 2026 09:13:20 -0500
Subject: [PATCH 7/9] fix(chat-with-content): stop surfacing raw
 session-exchange errors to viewers

resolve_visitor_client returned the raw exception text for display when the
session token exchange failed. A viewer can't act on SDK/vendor error detail,
so log it server-side instead and show only the self-contained detail string.
---
 extensions/chat-with-content/helpers.py      | 23 ++++++++++++--------
 extensions/chat-with-content/manifest.json   |  2 +-
 extensions/chat-with-content/test_helpers.py | 18 ++++++++-------
 3 files changed, 25 insertions(+), 18 deletions(-)

diff --git a/extensions/chat-with-content/helpers.py b/extensions/chat-with-content/helpers.py
index c18436ce..8c22960b 100644
--- a/extensions/chat-with-content/helpers.py
+++ b/extensions/chat-with-content/helpers.py
@@ -19,6 +19,9 @@ def running_on_connect():
 
 # What the viewer is told when their session can't be used at all. Adding the
 # integration fixes neither case, so these are kept separate from the setup screen.
+# Self-contained sentences: the underlying error, if any, goes to the server log
+# instead (see resolve_visitor_client), since a viewer can't act on it and it may
+# be full of vendor/SDK detail that isn't meant for them.
 NO_SESSION_DETAIL = (
     "Couldn't read your Connect session, so the app can't list or read content as "
     "you. Make sure you're signed in to Connect. If you are, your administrator may "
@@ -26,16 +29,15 @@ def running_on_connect():
 )
 EXCHANGE_FAILED_DETAIL = (
     "Couldn't read your Connect session, so the app can't list or read content as "
-    "you. The error was:"
+    "you. Contact your administrator; the technical detail is in the application logs."
 )
 
 
-# Returns (client, integration_enabled, session_error), where session_error is a
-# (detail, raw_error) pair when the viewer's session can't be used and raw_error is
-# None if there is no underlying exception worth showing. The gate is the point:
-# never fall back to the deploy client for a viewer, because that would list the
-# deployer's content as if it were theirs. Off Connect, the deploy client is the
-# intended one.
+# Returns (client, integration_enabled, session_error), where session_error is the
+# detail to show the viewer when their session can't be used, or None otherwise. The
+# gate is the point: never fall back to the deploy client for a viewer, because that
+# would list the deployer's content as if it were theirs. Off Connect, the deploy
+# client is the intended one.
 def resolve_visitor_client(client, on_connect, token):
     if not on_connect:
         return client, True, None
@@ -43,7 +45,7 @@ def resolve_visitor_client(client, on_connect, token):
         # No token means there is no signed-in viewer to act as: content that allows
         # anonymous access sends none, and OAuth integrations may be off server-wide.
         # Neither is fixed on the Access tab, so say that rather than showing setup.
-        return client, True, (NO_SESSION_DETAIL, None)
+        return client, True, NO_SESSION_DETAIL
     try:
         return client.with_user_session_token(token), True, None
     except Exception as err:
@@ -51,8 +53,11 @@ def resolve_visitor_client(client, on_connect, token):
         # missing-integration case (setup screen) rather than a scary error screen.
         if str(getattr(err, "error_code", "")) == "212":
             return client, False, None
+        # The raw error may be full of SDK/vendor detail a viewer can't act on, so it
+        # goes to the log for an administrator rather than onto the screen.
         raw = getattr(err, "error_message", None) or str(err)
-        return client, True, (EXCHANGE_FAILED_DETAIL, raw)
+        print(f"chat-with-content: session token exchange failed: {raw}")
+        return client, True, EXCHANGE_FAILED_DETAIL
 
 
 # Whether the app is fully set up and should load and use the viewer's content.
diff --git a/extensions/chat-with-content/manifest.json b/extensions/chat-with-content/manifest.json
index cdfed4d1..f23d12e9 100644
--- a/extensions/chat-with-content/manifest.json
+++ b/extensions/chat-with-content/manifest.json
@@ -44,7 +44,7 @@
       "checksum": "74b2cad1b559b06c306a1ee44ce00185"
     },
     "helpers.py": {
-      "checksum": "e0a4882026e3fef601e04469ddc11a01"
+      "checksum": "38a7f85e46e17a3c59fa9757e0ce88e8"
     }
   }
 }
diff --git a/extensions/chat-with-content/test_helpers.py b/extensions/chat-with-content/test_helpers.py
index 33ac00a4..bd779d93 100644
--- a/extensions/chat-with-content/test_helpers.py
+++ b/extensions/chat-with-content/test_helpers.py
@@ -109,12 +109,10 @@ def test_resolve_visitor_off_connect_uses_client_as_is():
 def test_resolve_visitor_no_token_on_connect_never_uses_the_deploy_client():
     # No session token on Connect must NOT fall back to the deploy client, which
     # would list the deployer's content as if it were the viewer's.
-    client, integration_enabled, session_error = helpers.resolve_visitor_client(
+    client, integration_enabled, detail = helpers.resolve_visitor_client(
         _FakeClient(), True, None
     )
-    assert session_error is not None
-    detail, raw = session_error
-    assert raw is None  # nothing technical to show; the cause is the access setup
+    assert detail is not None
     # It must NOT be reported as a missing integration: neither being signed out nor
     # server-wide OAuth being off is fixed by adding one, so the setup screen (which
     # integration_enabled=False would trigger) would be unactionable.
@@ -145,24 +143,28 @@ def test_resolve_visitor_missing_integration_string_code_requires_setup():
     assert helpers.resolve_visitor_client(c, True, "tok") == (c, False, None)
 
 
-def test_resolve_visitor_other_error_is_surfaced():
+def test_resolve_visitor_other_error_is_surfaced(capsys):
     c = _FakeClient(raises=_FakeError(error_code=5, error_message="permission denied"))
     assert helpers.resolve_visitor_client(c, True, "tok") == (
         c,
         True,
-        (helpers.EXCHANGE_FAILED_DETAIL, "permission denied"),
+        helpers.EXCHANGE_FAILED_DETAIL,
     )
+    # The technical detail isn't shown to the viewer (it may be full of SDK/vendor
+    # detail they can't act on), so it goes to the log for an administrator instead.
+    assert "permission denied" in capsys.readouterr().out
 
 
-def test_resolve_visitor_error_without_a_message_still_says_something():
+def test_resolve_visitor_error_without_a_message_still_says_something(capsys):
     # Not every failure is a ClientError with error_message; the viewer must still
     # be told why rather than getting an empty error screen.
     c = _FakeClient(raises=RuntimeError("connection refused"))
     assert helpers.resolve_visitor_client(c, True, "tok") == (
         c,
         True,
-        (helpers.EXCHANGE_FAILED_DETAIL, "connection refused"),
+        helpers.EXCHANGE_FAILED_DETAIL,
     )
+    assert "connection refused" in capsys.readouterr().out
 
 
 def test_the_two_session_failures_are_told_apart():

From fa39a221deb455e085975cee1edbee2639ab7cf4 Mon Sep 17 00:00:00 2001
From: Amy Lin 
Date: Wed, 29 Jul 2026 10:24:34 -0500
Subject: [PATCH 8/9] fix(chat-with-content): add SESSION_TIMEOUT_DETAIL
 alongside its siblings

The session-exchange-timeout message was hand-written inline in app.py with
different wording from NO_SESSION_DETAIL and EXCHANGE_FAILED_DETAIL, even
though it's the same category of failure (can't read the viewer's session).
Define it next to the other two so all three stay consistent.
---
 extensions/chat-with-content/helpers.py    | 6 ++++++
 extensions/chat-with-content/manifest.json | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/extensions/chat-with-content/helpers.py b/extensions/chat-with-content/helpers.py
index 8c22960b..b08ffdc4 100644
--- a/extensions/chat-with-content/helpers.py
+++ b/extensions/chat-with-content/helpers.py
@@ -31,6 +31,12 @@ def running_on_connect():
     "Couldn't read your Connect session, so the app can't list or read content as "
     "you. Contact your administrator; the technical detail is in the application logs."
 )
+# Raised by app.py, not resolve_visitor_client: the exchange itself has no timeout,
+# so app.py bounds the call with asyncio.wait_for and reports this on expiry.
+SESSION_TIMEOUT_DETAIL = (
+    "Couldn't read your Connect session, so the app can't list or read content as "
+    "you. Connect didn't respond in time; try reloading the page."
+)
 
 
 # Returns (client, integration_enabled, session_error), where session_error is the
diff --git a/extensions/chat-with-content/manifest.json b/extensions/chat-with-content/manifest.json
index f23d12e9..b5f9321b 100644
--- a/extensions/chat-with-content/manifest.json
+++ b/extensions/chat-with-content/manifest.json
@@ -44,7 +44,7 @@
       "checksum": "74b2cad1b559b06c306a1ee44ce00185"
     },
     "helpers.py": {
-      "checksum": "38a7f85e46e17a3c59fa9757e0ce88e8"
+      "checksum": "cf9684fe4097503d78c69724e795579b"
     }
   }
 }

From c5f53a33e1eff15d9c25f3e7af462cad65f61f93 Mon Sep 17 00:00:00 2001
From: Amy Lin 
Date: Sat, 1 Aug 2026 09:15:12 -0700
Subject: [PATCH 9/9] build(chat-with-content): pin the local interpreter and
 update setup-uv

uv resolved Python 3.10 locally while CI pinned 3.12 and Connect deploys
3.12.7, so contributors ran the suite on a different interpreter than either.
The workflow already solved this for CI; .python-version does it for local runs,
matching how the other extensions carry it (it was gitignored here).

setup-uv was four majors behind and out of step with the sibling workflows.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 .github/workflows/chat-with-content.yml      | 5 ++++-
 extensions/chat-with-content/.gitignore      | 1 -
 extensions/chat-with-content/.python-version | 1 +
 3 files changed, 5 insertions(+), 2 deletions(-)
 create mode 100644 extensions/chat-with-content/.python-version

diff --git a/.github/workflows/chat-with-content.yml b/.github/workflows/chat-with-content.yml
index 25c6315c..4655fbaf 100644
--- a/.github/workflows/chat-with-content.yml
+++ b/.github/workflows/chat-with-content.yml
@@ -29,7 +29,10 @@ jobs:
       # version, so pin the interpreter separately: without it uv resolves the
       # newest Python satisfying requires-python, and the tests would stop matching
       # the version Connect deploys on (manifest.json's python.version).
-      - uses: astral-sh/setup-uv@v5
+      # `uses` steps ignore the job's working-directory, so this can't read the
+      # extension's .python-version; it preinstalls the same version that file
+      # pins for `uv run` below. Keep the two in step.
+      - uses: astral-sh/setup-uv@v9.0.0
         with:
           pyproject-file: ./extensions/${{ env.EXTENSION_NAME }}/pyproject.toml
           python-version: "3.12"
diff --git a/extensions/chat-with-content/.gitignore b/extensions/chat-with-content/.gitignore
index 431d3059..cee72990 100644
--- a/extensions/chat-with-content/.gitignore
+++ b/extensions/chat-with-content/.gitignore
@@ -1,4 +1,3 @@
 rsconnect-python
 .venv
 .env
-.python-version
diff --git a/extensions/chat-with-content/.python-version b/extensions/chat-with-content/.python-version
new file mode 100644
index 00000000..e4fba218
--- /dev/null
+++ b/extensions/chat-with-content/.python-version
@@ -0,0 +1 @@
+3.12