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
1 change: 1 addition & 0 deletions .claude/skills/a0-bootstrap-plugin
1 change: 1 addition & 0 deletions .claude/skills/a0-plugin-architecture
1 change: 1 addition & 0 deletions .claude/skills/a0-plugin-e2e-bdd
1 change: 1 addition & 0 deletions .claude/skills/a0-plugin-testkit
1 change: 1 addition & 0 deletions .claude/skills/author-plugin-from-template
1 change: 1 addition & 0 deletions .claude/skills/plugin-manifest-contract
1 change: 1 addition & 0 deletions .claude/skills/rotate-plugin-credentials
1 change: 1 addition & 0 deletions .claude/skills/troubleshoot-plugin-deployment
6 changes: 6 additions & 0 deletions .devkit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
plugin_dir: usr/plugins/share_chat
display_name: Share Chat
# If the plugin has agent-driven behaviour needing an e2e seam, enable it here:
# e2e_pod_env:
# A0_SHARE_CHAT_TEST_PROBE: "1"
devkit_major: 2
20 changes: 20 additions & 0 deletions .gemini/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Gemini Code Assist configuration for the A0 plugin devkit (and, via the
# template, every plugin repo). https://developers.google.com/gemini-code-assist/docs/customize-repo-review
have_fun: false

code_review:
disable: false
comment_severity_threshold: LOW
max_review_comments: -1
pull_request_opened:
help: false
summary: true
code_review: true
include_drafts: true # the merge-guard drafts red PRs — still review them

ignore_patterns:
- "tests/_testkit/**" # the vendored devkit (reviewed in its own repo)
- "**/node_modules/**"
- "**/.features-gen/**" # generated by bddgen
- "**/test-results/**" # playwright artifacts
- "dist/**"
62 changes: 62 additions & 0 deletions .gemini/styleguide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# A0 plugin — Code Review Style Guide

Conventions and quality gates every PR to an Agent Zero plugin (or this devkit) must comply with.
Gemini Code Assist applies these during PR review — a **second, plain-language line of defense** in
front of the machine-checked CI gates (devkit `docs/BDD-GATES.md`, SPEC §5.14 / DEC-059–066). CI is the
hard gate; this review catches the same violations earlier and explains them. **Flag, don't rubber-stamp.**

The threat model is laziness — human *and* AI. If a diff looks like it's routing *around* a gate
(loosening a check, adding a bare skip, wrapping an assertion in try/catch), say so explicitly.

## Behaviour-first BDD — the `.feature` files (`tests/e2e/features/*.feature`)

- **Feature-purity (flag as HIGH):** a `Given/When/Then` step must read as plain behaviour. Flag any
selector (`.kebab-class`, `#id`), DOM/Playwright API (`querySelector`, `evaluate`, `dispatchEvent`),
store/internal name (`showModal`, `Alpine`, `getContext`, `callJsonApi`, `chat_create`), or framework
directive (`x-…`) appearing in a scenario. The "how" belongs in the step layer, never the feature.
- Scenarios assert **observable behaviour in domain language**, one behaviour per scenario, proper
Given/When/Then order. Behaviour is provoked by **real actions**, never by setting an internal flag.

## Honesty (flag as HIGH — these are the lies that pass CI)

- **No bare `@skip`.** Every skipped scenario must carry a `#` comment with the reason + a tracked
ref/issue. Flag any `@skip` without one.
- **No swallowed failures** in step files: an empty `catch {}`, or a `.catch(() => …)` / `try/catch`
that recovers instead of failing or re-asserting. Reserve try/catch for genuinely un-enableable env,
and even then assert *something*.
- **The four `docs/spec/` docs must exist** for a BDD plugin: `behaviour-spec.md`,
`implementation-plan.md`, `e2e.feature.md`, `e2e-steps-spec.md`. Flag a PR that adds
`tests/e2e/features/` without them.
- **Traceability:** every `BEH-n` in `behaviour-spec.md` must be covered in `e2e.feature.md` or listed
as a tracked skip. Flag orphans.
- **No fake green:** a scenario must genuinely assert. A suite that would pass with the plugin
uninstalled is fake-green (CI's seam-off red-proof catches it; flag designs that look hollow).

## The deterministic seam (`api/<plugin>_probe.py`)

- Must be **env-gated** (`A0_<PLUGIN>_TEST_PROBE`), off in production, and call the plugin's **real**
code path — never reimplement behaviour in the probe. Flag a seam that's ungated or that fakes logic.
- Seam calls belong in the **step layer**, never in a `.feature`.

## Fork-robustness (the tests run on the deployed fork, not stock A0)

- Flag a step that creates a **synthetic** chat context (`newContext()` with no backing chat) for the
plugin's polling path — the fork's chat-restore deselects it. Use a **real** chat (`chat_create`).
- Flag clicking a control without handling the no-LLM `composer-banner` overlay (hide it, then real
click; `dispatchEvent` does not trigger the framework handler).

## Plugin-specific vs common

- Plugins ship **only their own behaviour** features/steps. Flag any copy of the common lifecycle
(install/uninstall/boot/probe-enable) into a plugin — it ships from the devkit via `tests/_testkit`.

## Manifest & shipping

- `plugin.yaml` ↔ `<name>.meta.yaml` version match; declared `env[]` actually read in source; no UI
prompts for secrets (operator provides via chart). To ship verified, the gate meta needs
`source_repo` + `source_commit` with a green `plugin-e2e`.

## General

- No plaintext secrets. Idempotent lifecycle. Commit messages explain the *why*. Don't weaken a gate to
make a red build green — fix the underlying issue (see `docs/BDD-GATES.md` for each gate's fix).
8 changes: 7 additions & 1 deletion .github/workflows/plugin-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,20 @@
# (.devkit.yml `plugin_dir` → else canonical usr/plugins/<name>), so this
# template is identical across all repos.
#
# Needs the shared sync-App secrets to clone the private devkit submodule.
# Needs the shared sync-App secrets to clone the private devkit submodule, and
# (DEC-055) GHCR_PULL_TOKEN to pull the private fork image. `secrets: inherit`
# forwards all repo secrets, so new shared secrets need no caller change.
name: plugin-e2e

on:
pull_request:
branches: ["main", "master"]
workflow_dispatch:

permissions:
contents: read
pull-requests: write # merge-guard drafts a PR whose plugin-e2e is red

jobs:
e2e:
uses: agent-zero-plugins/agent-zero-plugin-development-testkit/.github/workflows/plugin-e2e.yml@main
Expand Down
File renamed without changes.
33 changes: 33 additions & 0 deletions docs/spec/implementation-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# share-chat — Implementation plan

How the product is built internally (the *product* counterpart to `e2e-steps-spec.md`). Behaviour lives
in `behaviour-spec.md`; the executable contract in `e2e.feature.md`.

## Components

| Component | Path | Responsibility |
|---|---|---|
| Share button (the whole plugin) | `usr/plugins/share_chat/extensions/webui/chat-top-end/share-chat.html` | An Alpine `x-data` island injected at the `chat-top-end` extension point. Renders one button and owns all behaviour + styling. |
| Manifest | `plugin.yaml` / `meta.yaml` | `name: share_chat`, UI-only, `env: []`, no config. |

There are **no** tools, API handlers, hooks, settings, or persisted state — the plugin is a single
front-end extension.

## Internals

- **State (one Alpine scope):** `{ copied: false, shareChat() }`. The `copied` flag drives the confirmation.
- **Link composition (`shareChat`):** read `globalThis.getContext()`; if falsy, return (no-op). Else take
`window.location.href`, **clear all query params** (`url.search = ''`), set only `ctxid=<context id>`,
and use the resulting string. This makes the link origin+path based (no auth/session leakage) and
re-selects the chat when opened.
- **Copy:** `await navigator.clipboard.writeText(link)`; on rejection (non-secure context) fall back to a
hidden `<textarea>` + `document.execCommand('copy')`. Either path sets `copied = true`.
- **Confirmation:** `copied` toggles the icon `share → check`, adds `.share-copied` (green accent), and
swaps the title to "Link copied!"; a `setTimeout(..., 2000)` resets it. Rapid clicks: the last click's
timer governs the reset.

## Configuration & dependencies

None. Upstream-compatible — uses only stock A0 surfaces: the `chat-top-end` webui extension point and the
chat-context model (`globalThis.getContext`). No `@extensible` fork seam; no e2e seam (there is no
agent-driven behaviour — the user clicks the button directly).
15 changes: 15 additions & 0 deletions tests/e2e/features/10-share.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Feature: Sharing a chat via a deep link
Scenario: The share control is present in the chat # BEH-1
Given I am in a chat
Then a share control is available in the chat toolbar

Scenario: Sharing copies a link to this chat # BEH-4, BEH-5
Given I am in a chat
When I share the chat
Then a link to this chat is copied to my clipboard
And the link identifies this conversation

Scenario: Sharing confirms the link was copied # BEH-7
Given I am in a chat
When I share the chat
Then the control confirms the link was copied
59 changes: 59 additions & 0 deletions tests/e2e/steps/share.steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Given, When, Then } from "../../_testkit/e2e/bdd/bdd-fixtures";
import { expect } from "@playwright/test";

// share-chat is a pure UI plugin (no LLM, no seam). We just need a REAL active chat
// (chat_create — the fork keeps it selected) so getContext() is truthy and the
// chat-top-end button renders.
let ctx = "";
const openChat = async (page: any) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForTimeout(1500);
ctx = await page.evaluate(async () => {
const { callJsonApi } = await import("/js/api.js");
const r = await callJsonApi("/chat_create", {});
const id = (r && (r.ctxid || r.context)) || "";
if (id) (globalThis as any).setContext(id);
return id;
});
if (!ctx) throw new Error("chat_create returned no ctxid");
};

Given("I am in a chat", async ({ loggedInPage }: any) => {
await openChat(loggedInPage);
});

Then("a share control is available in the chat toolbar", async ({ loggedInPage }: any) => {
await expect(loggedInPage.locator(".share-chat-btn")).toBeVisible({ timeout: 12000 });
});

When("I share the chat", async ({ loggedInPage }: any) => {
// Capture whatever the plugin copies (both the clipboard-API path and the
// execCommand fallback) so we can assert the link without OS clipboard read.
await loggedInPage.evaluate(() => {
(window as any).__copied = null;
if (navigator.clipboard) {
navigator.clipboard.writeText = (t: string) => { (window as any).__copied = t; return Promise.resolve(); };
}
document.execCommand = ((cmd: string) => {
if (cmd === "copy") { const ta = document.querySelector("textarea"); if (ta) (window as any).__copied = (ta as HTMLTextAreaElement).value; }
return true;
}) as any;
});
await expect(loggedInPage.locator(".share-chat-btn")).toBeVisible({ timeout: 12000 });
await loggedInPage.locator(".share-chat-btn").click();
});

Then("a link to this chat is copied to my clipboard", async ({ loggedInPage }: any) => {
const copied = await loggedInPage.evaluate(() => (window as any).__copied);
expect(copied, "the share button should have copied something").toBeTruthy();
});

Then("the link identifies this conversation", async ({ loggedInPage }: any) => {
const copied = await loggedInPage.evaluate(() => (window as any).__copied);
expect(String(copied)).toContain("ctxid=" + ctx);
});

Then("the control confirms the link was copied", async ({ loggedInPage }: any) => {
// On a successful copy the button gains the green "copied" accent (.share-copied).
await expect(loggedInPage.locator(".share-chat-btn.share-copied")).toBeVisible({ timeout: 5000 });
});
Loading