Compare commits

...

4 Commits

Author SHA1 Message Date
Peter Steinberger
7ec621ab58 chore: polish qa lab follow-ups 2026-04-05 09:13:50 +01:00
Peter Steinberger
8762df9bb4 feat: add qa lab extension 2026-04-05 09:13:49 +01:00
Peter Steinberger
dce0467826 refactor: hide qa channels with exposure metadata 2026-04-05 09:12:00 +01:00
Peter Steinberger
258484854b feat: add qa channel foundation 2026-04-05 09:12:00 +01:00
81 changed files with 5344 additions and 40 deletions

11
.github/labeler.yml vendored
View File

@@ -64,6 +64,17 @@
- any-glob-to-any-file:
- "extensions/qqbot/**"
- "docs/channels/qqbot.md"
"channel: qa-channel":
- changed-files:
- any-glob-to-any-file:
- "extensions/qa-channel/**"
- "docs/channels/qa-channel.md"
"extensions: qa-lab":
- changed-files:
- any-glob-to-any-file:
- "extensions/qa-lab/**"
- "docs/concepts/qa-e2e-automation.md"
- "docs/channels/qa-channel.md"
"channel: signal":
- changed-files:
- any-glob-to-any-file:

2
.gitignore vendored
View File

@@ -147,3 +147,5 @@ changelog/fragments/
.tmp/
test/fixtures/openclaw-vitest-unit-report.json
analysis/
.artifacts/qa-e2e/
extensions/qa-lab/web/dist/

View File

@@ -0,0 +1,99 @@
---
title: "QA Channel"
summary: "Synthetic Slack-class channel plugin for deterministic OpenClaw QA scenarios"
read_when:
- You are wiring the synthetic QA transport into a local or CI test run
- You need the bundled qa-channel config surface
- You are iterating on end-to-end QA automation
---
# QA Channel
`qa-channel` is a bundled synthetic message transport for automated OpenClaw QA.
It is not a production channel. It exists to exercise the same channel plugin
boundary used by real transports while keeping state deterministic and fully
inspectable.
## What it does today
- Slack-class target grammar:
- `dm:<user>`
- `channel:<room>`
- `thread:<room>/<thread>`
- HTTP-backed synthetic bus for:
- inbound message injection
- outbound transcript capture
- thread creation
- reactions
- edits
- deletes
- search and read actions
- Bundled host-side self-check runner that writes a Markdown report
## Config
```json
{
"channels": {
"qa-channel": {
"baseUrl": "http://127.0.0.1:43123",
"botUserId": "openclaw",
"botDisplayName": "OpenClaw QA",
"allowFrom": ["*"],
"pollTimeoutMs": 1000
}
}
}
```
Supported account keys:
- `baseUrl`
- `botUserId`
- `botDisplayName`
- `pollTimeoutMs`
- `allowFrom`
- `defaultTo`
- `actions.messages`
- `actions.reactions`
- `actions.search`
- `actions.threads`
## Runner
Current vertical slice:
```bash
pnpm qa:e2e
```
This now routes through the bundled `qa-lab` extension. It starts the in-repo
QA bus, boots the bundled `qa-channel` runtime slice, runs a deterministic
self-check, and writes a Markdown report under `.artifacts/qa-e2e/`.
Private debugger UI:
```bash
pnpm qa:lab:build
pnpm openclaw qa ui
```
That launches the private QA debugger at a local URL, separate from the
shipped Control UI bundle.
## Scope
Current scope is intentionally narrow:
- bus + plugin transport
- threaded routing grammar
- channel-owned message actions
- Markdown reporting
Follow-up work will add:
- Dockerized OpenClaw orchestration
- provider/model matrix execution
- richer scenario discovery
- OpenClaw-native orchestration later

View File

@@ -0,0 +1,865 @@
---
title: "QA E2E Automation"
summary: "Design note for a full end-to-end QA system built on a synthetic message-channel plugin, Dockerized OpenClaw, and subagent-driven scenario execution"
read_when:
- You are designing a true end-to-end QA harness for OpenClaw
- You want a synthetic message channel for automated feature verification
- You want subagents to discover features, run scenarios, and propose fixes
---
# QA E2E Automation
This note proposes a true end-to-end QA system for OpenClaw built around a
real channel plugin dedicated to testing.
The core idea:
- run OpenClaw inside Docker in a realistic gateway configuration
- expose a synthetic but full-featured message channel as a normal plugin
- let a QA harness inject inbound traffic and inspect outbound state
- let OpenClaw agents and subagents explore, verify, and report on behavior
- optionally escalate failing scenarios into host-side fix workflows that open PRs
This is not a unit-test replacement. It is a product-level system test layer.
## Chosen direction
The initial direction for this project is:
- build the full system inside this repo
- test against a matrix, not a single model/provider pair
- use Markdown reports as the first output artifact
- defer auto-PR and auto-fix work until later
- treat Slack-class semantics as the MVP transport target
- keep orchestration simple in v1, with a host-side controller that exercises
the moving parts directly
- evolve toward OpenClaw becoming the orchestration layer later, once the
transport, scenario, and reporting model are proven
## Goals
- Test OpenClaw through a real messaging-channel boundary, not only `chat.send`
or embedded mocks.
- Verify channel semantics that matter for real use:
- DMs
- channels/groups
- threads
- edits
- deletes
- reactions
- polls
- attachments
- Verify agent behavior across realistic user flows:
- memory
- thread binding
- model switching
- cron jobs
- subagents
- approvals
- routing
- channel-specific `message` actions
- Make the QA runner capable of feature discovery:
- read docs
- inspect plugin capability discovery
- inspect code and config
- generate a scenario protocol
- Support deterministic protocol tests and best-effort real-model tests as
separate lanes.
- Allow automated bug triage artifacts that can feed a host-side fix worker.
## Non-goals
- Not a replacement for existing unit, contract, or live tests.
- Not a production channel.
- Not a requirement that all bug fixing happen from inside the Dockerized
OpenClaw runtime.
- Not a reason to add test-only core branches for one channel.
## Why a channel plugin
OpenClaw already has the right boundary:
- core owns the shared `message` tool, prompt wiring, outer session
bookkeeping, and dispatch
- channel plugins own:
- config
- pairing
- security
- session grammar
- threading
- outbound delivery
- channel-owned actions and capability discovery
That means the cleanest design is:
- a real channel plugin for QA transport semantics
- a separate QA control plane for injection and inspection
This keeps the test transport inside the same architecture used by Slack,
Discord, Teams, and similar channels.
## System overview
The system has six pieces.
1. `qa-channel` plugin
- Bundled extension under `extensions/qa-channel`
- Normal `ChannelPlugin`
- Behaves like a Slack/Discord/Teams-class channel
- Registers channel-owned message actions through the shared `message` tool
2. `qa-bus` sidecar
- Small HTTP and/or WS service
- Canonical state store for synthetic conversations, messages, threads,
reactions, edits, and event history
- Accepts inbound events from the harness
- Exposes inspection and wait APIs for assertions
3. Dockerized OpenClaw gateway
- Runs as close to real deployment as practical
- Loads `qa-channel`
- Uses normal config, routing, session, cron, and plugin loading
4. QA orchestrator
- Host-side runner or dedicated OpenClaw-driven controller
- Provisions scenario environments
- Seeds config
- Resets state
- Executes test matrix
- Collects structured outcomes
5. Auto-fix worker
- Host-side workflow
- Creates a worktree
- launches a coding agent
- runs scoped verification
- opens a PR
The auto-fix worker should start outside the container. It needs direct repo
and GitHub access, clean worktree control, and better isolation from the
runtime under test.
6. `qa-lab` extension
- Bundled extension under `extensions/qa-lab`
- Owns the QA harness, Markdown report flow, and private debugger UI
- Registers hidden CLI entrypoints such as `openclaw qa run` and
`openclaw qa ui`
- Stays separate from the shipped Control UI bundle
## High-level flow
1. Start `qa-bus`.
2. Start OpenClaw in Docker with `qa-channel` enabled.
3. QA orchestrator injects inbound messages into `qa-bus`.
4. `qa-channel` receives them as normal inbound traffic.
5. OpenClaw runs the agent loop normally.
6. Outbound replies and channel actions flow back through `qa-channel` into
`qa-bus`.
7. QA orchestrator inspects state or waits on events.
8. Orchestrator records pass/fail/flaky/unknown plus artifacts.
9. Severe failures optionally emit a bug packet for the host-side fix worker.
## Lanes
The system should have two distinct lanes.
### Lane A: deterministic protocol lane
Use a deterministic or tightly controlled model setup.
Preferred options:
- a canned provider fixture
- the bundled `synthetic` provider when useful
- fixed prompts with exact assertions
Purpose:
- verify transport and product semantics
- keep flakiness low
- catch regressions in routing, memory plumbing, thread binding, cron, and tool
invocation
### Lane B: quality lane
Use real providers and real models in a matrix.
Purpose:
- verify that the agent can still do good work end to end
- evaluate feature discoverability and instruction following
- surface model-specific breakage or degraded behavior
Expected result type:
- best-effort
- rubric-based
- more tolerant of wording variation
Matrix guidance for v1:
- start with a small curated matrix, not "everything configured"
- keep deterministic protocol runs separate from quality runs
- report matrix cells independently so one provider/model failure does not hide
transport correctness
Do not mix these lanes. Protocol correctness and model quality should fail
independently.
## Use existing bootstrap seam first
Before the custom channel exists, OpenClaw already has a useful bootstrap path:
- admin-scoped synthetic originating-route fields on `chat.send`
- synthetic message-channel headers for HTTP flows
That is enough to build a first QA controller for:
- thread/session routing
- ACP bind flows
- subagent delivery
- cron wake paths
- memory persistence checks
This should be Phase 0 because it de-risks the scenario protocol before the
full channel lands.
## `qa-lab` extension design
`qa-lab` is the private operator-facing half of this system.
Suggested package:
- `extensions/qa-lab/`
Suggested responsibilities:
- host the synthetic bus state machine
- host the scenario runner
- write Markdown reports
- serve a private debugger UI on a separate local server
- keep that UI entirely outside the shipped Control UI bundle
Suggested UI shape:
- left rail for conversations and threads
- center transcript pane
- right rail for event stream and report inspection
- bottom inject-composer for inbound QA traffic
## `qa-channel` plugin design
## Package layout
Suggested package:
- `extensions/qa-channel/`
Suggested file layout:
- `package.json`
- `openclaw.plugin.json`
- `index.ts`
- `setup-entry.ts`
- `api.ts`
- `runtime-api.ts`
- `src/channel.ts`
- `src/channel-api.ts`
- `src/config-schema.ts`
- `src/setup-core.ts`
- `src/setup-surface.ts`
- `src/runtime.ts`
- `src/channel.runtime.ts`
- `src/inbound.ts`
- `src/outbound.ts`
- `src/state-client.ts`
- `src/targets.ts`
- `src/threading.ts`
- `src/message-actions.ts`
- `src/probe.ts`
- `src/doctor.ts`
- `src/*.test.ts`
Model it after Slack, Discord, Teams, or Google Chat packaging, not as a one-off
test helper.
## Capabilities
MVP capabilities:
- one account
- DMs
- channels
- threads
- send text
- reply in thread
- read
- edit
- delete
- react
- search
- upload-file
- download-file
Phase 2 capabilities:
- polls
- member-info
- channel-info
- channel-list
- pin and unpin
- permissions
- topic create and edit
These map naturally onto the shared `message` tool action model already used by
channel plugins.
## Conversation model
Use a stable synthetic grammar that supports both simplicity and realistic
coverage.
Suggested ids:
- DM conversation: `dm:<user-id>`
- channel: `chan:<space-id>`
- thread: `thread:<space-id>:<thread-id>`
- message id: `msg:<ulid>`
Suggested target forms:
- `qa:dm:<user-id>`
- `qa:chan:<space-id>`
- `qa:thread:<space-id>:<thread-id>`
The plugin should own translation between external target strings and canonical
conversation ids.
## Pairing and security
Even though this is a QA channel, it should still implement real policy
surfaces:
- DM allowlist / pairing flow
- group policy
- mention gating where relevant
- trusted sender ids
Reason:
- these are product features and should be testable through the QA transport
- the QA lane should be able to verify policy failures, not only happy paths
## Threading model
Threading is one of the main reasons to build this channel.
Required semantics:
- create thread from a top-level message
- reply inside an existing thread
- list thread messages
- preserve parent message linkage
- let OpenClaw thread binding attach a session to a thread
The QA bus must preserve:
- conversation id
- thread id
- parent message id
- sender id
- timestamps
## Channel-owned message actions
The plugin should implement `actions.describeMessageTool(...)` and
`actions.handleAction(...)`.
MVP action list:
- `send`
- `read`
- `reply`
- `react`
- `edit`
- `delete`
- `thread-create`
- `thread-reply`
- `search`
- `upload-file`
- `download-file`
This is enough to test the shared `message` tool end to end with real channel
semantics.
## `qa-bus` design
`qa-bus` is the transport simulator and assertion backend.
It should not know OpenClaw internals. It should know channel state.
For v1, keep `qa-bus` in this repo so:
- fixtures and scenarios evolve with product code
- the transport contract can change in lock-step with the plugin
- CI and local dev do not need another repo checkout
## Responsibilities
- accept inbound user/platform events
- persist canonical conversation state
- persist append-only event log
- expose inspection APIs
- expose blocking wait APIs
- support reset per scenario or per suite
## Transport
HTTP is enough for MVP.
Suggested endpoints:
- `POST /reset`
- `POST /inbound/message`
- `POST /inbound/edit`
- `POST /inbound/delete`
- `POST /inbound/reaction`
- `POST /inbound/thread/create`
- `GET /state/conversations`
- `GET /state/messages`
- `GET /state/threads`
- `GET /events`
- `POST /wait`
Optional WS stream:
- `/stream`
Useful for live event taps and debugging.
## State model
Persist three layers.
1. Conversation snapshot
- participants
- type
- thread topology
- latest message pointers
2. Message snapshot
- sender
- content
- attachments
- edit history
- reactions
- parent and thread linkage
3. Append-only event log
- canonical timestamp
- causal ordering
- source: inbound, outbound, action, system
- payload
The append-only log matters because many QA assertions are event-oriented, not
just state-oriented.
## Assertion API
The harness needs waiters, not just snapshots.
Suggested `POST /wait` contract:
- `kind`
- `match`
- `timeoutMs`
Examples:
- wait for outbound message matching text regex
- wait for thread creation
- wait for reaction added
- wait for message edit
- wait for no event of type X within Y ms
This gives stable tests without custom polling code in every scenario.
## QA orchestrator design
The orchestrator should own scenario planning and artifact collection.
Start host-side. Later, OpenClaw can orchestrate parts of it.
This is the chosen v1 direction.
Why:
- simpler to iterate while the transport and scenario protocol are still moving
- easier access to the repo, logs, Docker, and test fixtures
- easier artifact collection and report generation
- avoids over-coupling the first version to subagent behavior before the QA
protocol itself is stable
## Inputs
- docs pages
- channel capability discovery
- configured provider/model lane
- scenario catalog
- repo/test metadata
## Outputs
- structured protocol report
- scenario transcript
- captured channel state
- gateway logs
- failure packets
For v1, the primary output is a Markdown report.
Suggested report sections:
- suite summary
- environment
- provider/model matrix
- scenarios passed
- scenarios failed
- flaky or inconclusive scenarios
- captured evidence links or inline excerpts
- suspected ownership or file hints
- follow-up recommendations
## Scenario format
Use a data-driven scenario spec.
Suggested shape:
```json
{
"id": "thread-memory-recall",
"lane": "deterministic",
"preconditions": ["qa-channel", "memory-enabled"],
"steps": [
{
"type": "injectMessage",
"to": "qa:dm:user-a",
"text": "Remember that the deploy key is kiwi."
},
{ "type": "waitForOutbound", "match": { "textIncludes": "kiwi" } },
{ "type": "injectMessage", "to": "qa:dm:user-a", "text": "What was the deploy key?" },
{ "type": "waitForOutbound", "match": { "textIncludes": "kiwi" } }
],
"assertions": [{ "type": "outboundTextIncludes", "value": "kiwi" }]
}
```
Keep the execution engine generic and the scenario catalog declarative.
## Feature discovery
The orchestrator can discover candidate scenarios from three sources.
1. Docs
- channel docs
- testing docs
- gateway docs
- subagents docs
- cron docs
2. Runtime capability discovery
- channel `message` action discovery
- plugin status and channel capabilities
- configured providers/models
3. Code hints
- known action names
- channel-specific feature flags
- config schema
This should produce a proposed protocol with:
- must-test
- can-test
- blocked
- unsupported
## Scenario classes
Recommended catalog:
- transport basics
- DM send and reply
- channel send
- thread create and reply
- reaction add and read
- edit and delete
- policy
- allowlist
- pairing
- group mention gating
- shared `message` tool
- read
- search
- reply
- react
- upload and download
- agent quality
- follows channel context
- obeys thread semantics
- uses memory across turns
- switches model when instructed
- automation
- cron add and run
- cron delivery into channel
- scheduled reminders
- subagents
- spawn
- announce
- threaded follow-up
- nested orchestration when enabled
- failure handling
- unsupported action
- timeout
- malformed target
- policy denial
## OpenClaw as orchestrator
Longer-term, OpenClaw itself can coordinate the QA run.
Suggested architecture:
- one controller session
- N worker subagents
- each worker owns one scenario or scenario shard
- workers report structured results back to controller
Good fits for existing OpenClaw primitives:
- `sessions_spawn`
- `subagents`
- cron-based wakeups for long-running suites
- thread-bound sessions for scenario-local follow-up
Best near-term use:
- controller generates the plan
- workers execute scenarios in parallel
- controller synthesizes report
Avoid making the controller also own host Git operations in the first version.
Chosen direction:
- v1: host-side controller
- v2+: OpenClaw-native orchestration once the scenario protocol and transport
model are stable
## Auto-fix workflow
The system should emit a structured bug packet when a scenario fails.
Suggested bug packet:
- scenario id
- lane
- failure kind
- minimal repro steps
- channel event transcript
- gateway transcript
- logs
- suspected files
- confidence
Host-side fix worker flow:
1. receive bug packet
2. create detached worktree
3. launch coding agent in worktree
4. write failing regression first when practical
5. implement fix
6. run scoped verification
7. open PR
This should remain host-side at first because it needs:
- repo write access
- worktree hygiene
- git credentials
- GitHub auth
Chosen direction:
- do not auto-open PRs in v1
- emit Markdown reports and structured failure packets first
- add host-side worktree + PR automation later
## Rollout plan
## Phase 0: bootstrap on existing synthetic ingress
Build a first QA runner without a new channel:
- use `chat.send` with admin-scoped synthetic originating-route fields
- run deterministic scenarios against routing, memory, cron, subagents, and ACP
- validate protocol format and artifact collection
Exit criteria:
- scenario runner exists
- structured protocol report exists
- failure artifacts exist
## Phase 1: MVP `qa-channel`
Build the plugin and bus with:
- DM
- channels
- threads
- read
- reply
- react
- edit
- delete
- search
Target semantics:
- Slack-class transport behavior
- not full Teams-class parity yet
Exit criteria:
- OpenClaw in Docker can talk to `qa-bus`
- harness can inject + inspect
- one green end-to-end suite across message transport and agent behavior
## Phase 2: protocol expansion
Add:
- attachments
- polls
- pins
- richer policy tests
- quality lane with real provider/model matrix
Exit criteria:
- scenario matrix covers major built-in features
- deterministic and quality lanes are separated
## Phase 3: subagent-driven QA
Add:
- controller agent
- worker subagents
- scenario discovery from docs + capability discovery
- parallel execution
Exit criteria:
- one controller can fan out and synthesize a suite report
## Phase 4: auto-fix loop
Add:
- bug packet emission
- host-side worktree runner
- PR creation
Exit criteria:
- selected failures can auto-produce draft PRs
## Risks
## Risk: too much magic in one layer
If the QA channel, bus, and orchestrator all become smart at once, debugging
will be painful.
Mitigation:
- keep `qa-channel` transport-focused
- keep `qa-bus` state-focused
- keep orchestrator separate
## Risk: flaky assertions from model variance
Mitigation:
- deterministic lane
- quality lane
- different pass criteria
## Risk: test-only branches leaking into core
Mitigation:
- no core special cases for `qa-channel`
- use normal plugin seams
- use admin synthetic ingress only as bootstrap
## Risk: auto-fix overreach
Mitigation:
- keep fix worker host-side
- require explicit policy for when PRs can open automatically
- gate with scoped tests
## Risk: building a fake platform nobody uses
Mitigation:
- emulate Slack/Discord/Teams semantics, not an abstract transport
- prioritize features that stress shared OpenClaw boundaries
## MVP recommendation
If building this now, start with this exact order.
1. Host-side scenario runner using existing synthetic originating-route support.
2. `qa-bus` sidecar with state, events, reset, and wait APIs.
3. `extensions/qa-channel` MVP with DMs, channels, threads, reply, read, react,
edit, delete, and search.
4. Markdown report generator for suite + matrix output.
5. One deterministic end-to-end suite:
- inject inbound DM
- verify reply
- create thread
- verify follow-up in thread
- verify memory recall on later turn
6. Add curated real-model matrix quality lane.
7. Add controller subagent orchestration.
8. Add host-side auto-fix worktree runner.
This order gets real value quickly without requiring the full grand design to
land before the first useful signal appears.
## Current product decisions
- `qa-bus` lives inside this repo
- the first controller is host-side
- Slack-class behavior is the MVP target
- the quality lane uses a curated matrix
- first version produces Markdown reports, not PRs
- OpenClaw-native orchestration is a later phase, not a v1 requirement

View File

@@ -1455,7 +1455,10 @@ Useful `openclaw.channel` fields beyond the minimal example:
- `preferOver`: lower-priority plugin/channel ids this catalog entry should outrank
- `selectionDocsPrefix`, `selectionDocsOmitLabel`, `selectionExtras`: selection-surface copy controls
- `markdownCapable`: marks the channel as markdown-capable for outbound formatting decisions
- `showConfigured`: hide the channel from configured-channel listing surfaces when set to `false`
- `exposure.configured`: hide the channel from configured-channel listing surfaces when set to `false`
- `exposure.setup`: hide the channel from interactive setup/configure pickers when set to `false`
- `exposure.docs`: mark the channel as internal/private for docs navigation surfaces
- `showConfigured` / `showInSetup`: legacy aliases still accepted for compatibility; prefer `exposure`
- `quickstartAllowFrom`: opt the channel into the standard quickstart `allowFrom` flow
- `forceAccountBinding`: require explicit account binding even when only one account exists
- `preferSessionLookupForAnnounceTarget`: prefer session lookup when resolving announce targets

View File

@@ -101,7 +101,7 @@ surfaces before runtime loads.
| `selectionDocsOmitLabel` | `boolean` | Show the docs path directly instead of a labeled docs link in selection copy. |
| `selectionExtras` | `string[]` | Extra short strings appended in selection copy. |
| `markdownCapable` | `boolean` | Marks the channel as markdown-capable for outbound formatting decisions. |
| `showConfigured` | `boolean` | Controls whether configured-channel listing surfaces show this channel. |
| `exposure` | `object` | Channel visibility controls for setup, configured lists, and docs surfaces. |
| `quickstartAllowFrom` | `boolean` | Opt this channel into the standard quickstart `allowFrom` setup flow. |
| `forceAccountBinding` | `boolean` | Require explicit account binding even when only one account exists. |
| `preferSessionLookupForAnnounceTarget` | `boolean` | Prefer session lookup when resolving announce targets for this channel. |
@@ -125,12 +125,26 @@ Example:
"selectionDocsPrefix": "Guide:",
"selectionExtras": ["Markdown"],
"markdownCapable": true,
"exposure": {
"configured": true,
"setup": true,
"docs": true
},
"quickstartAllowFrom": true
}
}
}
```
`exposure` supports:
- `configured`: include the channel in configured/status-style listing surfaces
- `setup`: include the channel in interactive setup/configure pickers
- `docs`: mark the channel as public-facing in docs/navigation surfaces
`showConfigured` and `showInSetup` remain supported as legacy aliases. Prefer
`exposure`.
### `openclaw.install`
`openclaw.install` is package metadata, not manifest metadata.

View File

@@ -0,0 +1,5 @@
export * from "./src/accounts.js";
export * from "./src/channel.js";
export * from "./src/channel-actions.js";
export * from "./src/runtime.js";
export * from "./test-api.js";

View File

@@ -0,0 +1,15 @@
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
import { qaChannelPlugin } from "./src/channel.js";
import { setQaChannelRuntime } from "./src/runtime.js";
export { qaChannelPlugin } from "./src/channel.js";
export { setQaChannelRuntime } from "./src/runtime.js";
export default defineChannelPluginEntry({
id: "qa-channel",
name: "QA Channel",
description: "Synthetic QA channel plugin",
plugin: qaChannelPlugin as ChannelPlugin,
setRuntime: setQaChannelRuntime,
});

View File

@@ -0,0 +1,9 @@
{
"id": "qa-channel",
"channels": ["qa-channel"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,45 @@
{
"name": "@openclaw/qa-channel",
"version": "2026.4.4",
"private": true,
"description": "OpenClaw QA synthetic channel plugin",
"type": "module",
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.4"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "qa-channel",
"label": "QA Channel",
"selectionLabel": "QA Channel (Synthetic)",
"detailLabel": "QA Channel",
"docsPath": "/channels/qa-channel",
"docsLabel": "qa-channel",
"blurb": "Synthetic Slack-class transport for automated OpenClaw QA scenarios.",
"systemImage": "checklist",
"order": 999,
"exposure": {
"configured": false,
"setup": false,
"docs": false
}
},
"install": {
"npmSpec": "@openclaw/qa-channel",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.4"
}
}
}

View File

@@ -0,0 +1 @@
export * from "./src/runtime-api.js";

View File

@@ -0,0 +1,4 @@
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
import { qaChannelPlugin } from "./src/channel.js";
export default defineSetupPluginEntry(qaChannelPlugin);

View File

@@ -0,0 +1,61 @@
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import type { CoreConfig, QaChannelAccountConfig, ResolvedQaChannelAccount } from "./types.js";
const DEFAULT_POLL_TIMEOUT_MS = 1_000;
const {
listAccountIds: listQaChannelAccountIds,
resolveDefaultAccountId: resolveDefaultQaChannelAccountId,
} = createAccountListHelpers("qa-channel", { normalizeAccountId });
export { listQaChannelAccountIds, resolveDefaultQaChannelAccountId };
function resolveMergedQaAccountConfig(cfg: CoreConfig, accountId: string): QaChannelAccountConfig {
return resolveMergedAccountConfig<QaChannelAccountConfig>({
channelConfig: cfg.channels?.["qa-channel"] as QaChannelAccountConfig | undefined,
accounts: cfg.channels?.["qa-channel"]?.accounts as
| Record<string, Partial<QaChannelAccountConfig>>
| undefined,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
});
}
export function resolveQaChannelAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedQaChannelAccount {
const accountId = normalizeAccountId(params.accountId);
const merged = resolveMergedQaAccountConfig(params.cfg, accountId);
const baseEnabled = params.cfg.channels?.["qa-channel"]?.enabled !== false;
const enabled = baseEnabled && merged.enabled !== false;
const baseUrl = merged.baseUrl?.trim() ?? "";
const botUserId = merged.botUserId?.trim() || "openclaw";
const botDisplayName = merged.botDisplayName?.trim() || "OpenClaw QA";
return {
accountId,
enabled,
configured: Boolean(baseUrl),
name: merged.name?.trim() || undefined,
baseUrl,
botUserId,
botDisplayName,
pollTimeoutMs: merged.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS,
config: {
...merged,
allowFrom: merged.allowFrom ?? ["*"],
},
};
}
export function listEnabledQaChannelAccounts(cfg: CoreConfig): ResolvedQaChannelAccount[] {
return listQaChannelAccountIds(cfg)
.map((accountId) => resolveQaChannelAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
export { DEFAULT_ACCOUNT_ID };
export type { ResolvedQaChannelAccount } from "./types.js";

View File

@@ -0,0 +1,224 @@
import type {
QaBusConversation,
QaBusEvent,
QaBusInboundMessageInput,
QaBusMessage,
QaBusPollResult,
QaBusSearchMessagesInput,
QaBusStateSnapshot,
QaBusThread,
} from "./protocol.js";
export type {
QaBusConversation,
QaBusConversationKind,
QaBusCreateThreadInput,
QaBusDeleteMessageInput,
QaBusEditMessageInput,
QaBusEvent,
QaBusInboundMessageInput,
QaBusMessage,
QaBusOutboundMessageInput,
QaBusPollInput,
QaBusPollResult,
QaBusReactToMessageInput,
QaBusReadMessageInput,
QaBusSearchMessagesInput,
QaBusStateSnapshot,
QaBusThread,
QaBusWaitForInput,
} from "./protocol.js";
type JsonResult<T> = Promise<T>;
async function postJson<T>(
baseUrl: string,
path: string,
body: unknown,
signal?: AbortSignal,
): JsonResult<T> {
const response = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(body),
signal,
});
const payload = (await response.json()) as T | { error?: string };
if (!response.ok) {
const error =
typeof payload === "object" && payload && "error" in payload ? payload.error : undefined;
throw new Error(error || `qa-bus request failed: ${response.status}`);
}
return payload as T;
}
export function normalizeQaTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
return trimmed;
}
export function parseQaTarget(raw: string): {
chatType: "direct" | "channel";
conversationId: string;
threadId?: string;
} {
const normalized = normalizeQaTarget(raw);
if (!normalized) {
throw new Error("qa-channel target is required");
}
if (normalized.startsWith("thread:")) {
const rest = normalized.slice("thread:".length);
const slashIndex = rest.indexOf("/");
if (slashIndex <= 0 || slashIndex === rest.length - 1) {
throw new Error(`invalid qa-channel thread target: ${normalized}`);
}
return {
chatType: "channel",
conversationId: rest.slice(0, slashIndex),
threadId: rest.slice(slashIndex + 1),
};
}
if (normalized.startsWith("channel:")) {
return {
chatType: "channel",
conversationId: normalized.slice("channel:".length),
};
}
if (normalized.startsWith("dm:")) {
return {
chatType: "direct",
conversationId: normalized.slice("dm:".length),
};
}
return {
chatType: "direct",
conversationId: normalized,
};
}
export function buildQaTarget(params: {
chatType: "direct" | "channel";
conversationId: string;
threadId?: string | null;
}) {
if (params.threadId) {
return `thread:${params.conversationId}/${params.threadId}`;
}
return `${params.chatType === "direct" ? "dm" : "channel"}:${params.conversationId}`;
}
export async function pollQaBus(params: {
baseUrl: string;
accountId: string;
cursor: number;
timeoutMs: number;
signal?: AbortSignal;
}): Promise<QaBusPollResult> {
return await postJson<QaBusPollResult>(
params.baseUrl,
"/v1/poll",
{
accountId: params.accountId,
cursor: params.cursor,
timeoutMs: params.timeoutMs,
},
params.signal,
);
}
export async function sendQaBusMessage(params: {
baseUrl: string;
accountId: string;
to: string;
text: string;
senderId?: string;
senderName?: string;
threadId?: string;
replyToId?: string;
}) {
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/outbound/message", params);
}
export async function createQaBusThread(params: {
baseUrl: string;
accountId: string;
conversationId: string;
title: string;
createdBy?: string;
}) {
return await postJson<{ thread: QaBusThread }>(
params.baseUrl,
"/v1/actions/thread-create",
params,
);
}
export async function reactToQaBusMessage(params: {
baseUrl: string;
accountId: string;
messageId: string;
emoji: string;
senderId?: string;
}) {
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/actions/react", params);
}
export async function editQaBusMessage(params: {
baseUrl: string;
accountId: string;
messageId: string;
text: string;
}) {
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/actions/edit", params);
}
export async function deleteQaBusMessage(params: {
baseUrl: string;
accountId: string;
messageId: string;
}) {
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/actions/delete", params);
}
export async function readQaBusMessage(params: {
baseUrl: string;
accountId: string;
messageId: string;
}) {
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/actions/read", params);
}
export async function searchQaBusMessages(params: {
baseUrl: string;
input: QaBusSearchMessagesInput;
}) {
return await postJson<{ messages: QaBusMessage[] }>(
params.baseUrl,
"/v1/actions/search",
params.input,
);
}
export async function injectQaBusInboundMessage(params: {
baseUrl: string;
input: QaBusInboundMessageInput;
}) {
return await postJson<{ message: QaBusMessage }>(
params.baseUrl,
"/v1/inbound/message",
params.input,
);
}
export async function getQaBusState(baseUrl: string): Promise<QaBusStateSnapshot> {
const response = await fetch(`${baseUrl}/v1/state`);
if (!response.ok) {
throw new Error(`qa-bus request failed: ${response.status}`);
}
return (await response.json()) as QaBusStateSnapshot;
}

View File

@@ -0,0 +1,193 @@
import { Type } from "@sinclair/typebox";
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/core";
import { resolveQaChannelAccount } from "./accounts.js";
import {
createQaBusThread,
deleteQaBusMessage,
editQaBusMessage,
parseQaTarget,
reactToQaBusMessage,
readQaBusMessage,
searchQaBusMessages,
sendQaBusMessage,
} from "./bus-client.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "./runtime-api.js";
import type { CoreConfig } from "./types.js";
function listQaChannelActions(
cfg: CoreConfig,
accountId?: string | null,
): ChannelMessageActionName[] {
const account = resolveQaChannelAccount({ cfg, accountId });
if (!account.enabled || !account.configured) {
return [];
}
const actions = new Set<ChannelMessageActionName>(["send"]);
if (account.config.actions?.messages !== false) {
actions.add("read");
actions.add("edit");
actions.add("delete");
}
if (account.config.actions?.reactions !== false) {
actions.add("react");
actions.add("reactions");
}
if (account.config.actions?.threads !== false) {
actions.add("thread-create");
actions.add("thread-reply");
}
if (account.config.actions?.search !== false) {
actions.add("search");
}
return Array.from(actions);
}
export const qaChannelMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: (context) => ({
actions: listQaChannelActions(context.cfg as CoreConfig, context.accountId),
capabilities: [],
schema: {
properties: {
channelId: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
messageId: Type.Optional(Type.String()),
emoji: Type.Optional(Type.String()),
title: Type.Optional(Type.String()),
query: Type.Optional(Type.String()),
},
},
}),
extractToolSend: ({ args }: { args: Record<string, unknown> }) => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action === "sendMessage") {
const to = typeof args.to === "string" ? args.to : undefined;
return to ? { to } : null;
}
if (action === "threadReply") {
const channelId = typeof args.channelId === "string" ? args.channelId.trim() : "";
const threadId = typeof args.threadId === "string" ? args.threadId.trim() : "";
return channelId && threadId ? { to: `thread:${channelId}/${threadId}` } : null;
}
return null;
},
handleAction: async (context) => {
const { action, cfg, accountId, params } = context;
const account = resolveQaChannelAccount({ cfg: cfg as CoreConfig, accountId });
const baseUrl = account.baseUrl;
switch (action) {
case "thread-create": {
const channelId =
readStringParam(params, "channelId") ??
(() => {
const to = readStringParam(params, "to");
return to ? parseQaTarget(to).conversationId : undefined;
})();
const title = readStringParam(params, "title") ?? "QA thread";
if (!channelId) {
throw new Error("qa-channel thread-create requires channelId");
}
const { thread } = await createQaBusThread({
baseUrl,
accountId: account.accountId,
conversationId: channelId,
title,
createdBy: account.botUserId,
});
return jsonResult({
thread,
target: `thread:${channelId}/${thread.id}`,
});
}
case "thread-reply": {
const channelId = readStringParam(params, "channelId");
const threadId = readStringParam(params, "threadId");
const text = readStringParam(params, "text");
if (!channelId || !threadId || !text) {
throw new Error("qa-channel thread-reply requires channelId, threadId, and text");
}
const { message } = await sendQaBusMessage({
baseUrl,
accountId: account.accountId,
to: `thread:${channelId}/${threadId}`,
text,
senderId: account.botUserId,
senderName: account.botDisplayName,
threadId,
});
return jsonResult({ message });
}
case "react": {
const messageId = readStringParam(params, "messageId");
const emoji = readStringParam(params, "emoji");
if (!messageId || !emoji) {
throw new Error("qa-channel react requires messageId and emoji");
}
const { message } = await reactToQaBusMessage({
baseUrl,
accountId: account.accountId,
messageId,
emoji,
senderId: account.botUserId,
});
return jsonResult({ message });
}
case "reactions":
case "read": {
const messageId = readStringParam(params, "messageId");
if (!messageId) {
throw new Error(`qa-channel ${action} requires messageId`);
}
const { message } = await readQaBusMessage({
baseUrl,
accountId: account.accountId,
messageId,
});
return jsonResult({ message });
}
case "edit": {
const messageId = readStringParam(params, "messageId");
const text = readStringParam(params, "text");
if (!messageId || !text) {
throw new Error("qa-channel edit requires messageId and text");
}
const { message } = await editQaBusMessage({
baseUrl,
accountId: account.accountId,
messageId,
text,
});
return jsonResult({ message });
}
case "delete": {
const messageId = readStringParam(params, "messageId");
if (!messageId) {
throw new Error("qa-channel delete requires messageId");
}
const { message } = await deleteQaBusMessage({
baseUrl,
accountId: account.accountId,
messageId,
});
return jsonResult({ message });
}
case "search": {
const query = readStringParam(params, "query");
const channelId = readStringParam(params, "channelId");
const threadId = readStringParam(params, "threadId");
const { messages } = await searchQaBusMessages({
baseUrl,
input: {
accountId: account.accountId,
query,
conversationId: channelId,
threadId,
},
});
return jsonResult({ messages });
}
default:
throw new Error(`qa-channel action not implemented: ${action}`);
}
},
};

View File

@@ -0,0 +1,225 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { describe, expect, it } from "vitest";
import { extractToolPayload } from "../../../src/infra/outbound/tool-payload.js";
import { startQaBusServer } from "../../../src/qa-e2e/bus-server.js";
import { createQaBusState } from "../../../src/qa-e2e/bus-state.js";
import { createStartAccountContext } from "../../../test/helpers/plugins/start-account-context.js";
import { qaChannelPlugin } from "../api.js";
import { setQaChannelRuntime } from "../api.js";
function createMockQaRuntime(): PluginRuntime {
const sessionUpdatedAt = new Map<string, number>();
return {
channel: {
routing: {
resolveAgentRoute({
accountId,
peer,
}: {
accountId?: string | null;
peer?: { kind?: string; id?: string } | null;
}) {
return {
agentId: "qa-agent",
channel: "qa-channel",
accountId: accountId ?? "default",
sessionKey: `qa-agent:${peer?.kind ?? "direct"}:${peer?.id ?? "default"}`,
mainSessionKey: "qa-agent:main",
lastRoutePolicy: "session",
matchedBy: "default",
};
},
},
session: {
resolveStorePath(_store: string | undefined, { agentId }: { agentId: string }) {
return agentId;
},
readSessionUpdatedAt({ sessionKey }: { sessionKey: string }) {
return sessionUpdatedAt.get(sessionKey);
},
recordInboundSession({ sessionKey }: { sessionKey: string }) {
sessionUpdatedAt.set(sessionKey, Date.now());
},
},
reply: {
resolveEnvelopeFormatOptions() {
return {};
},
formatAgentEnvelope({ body }: { body: string }) {
return body;
},
finalizeInboundContext(ctx: Record<string, unknown>) {
return ctx as typeof ctx & { CommandAuthorized: boolean };
},
async dispatchReplyWithBufferedBlockDispatcher({
ctx,
dispatcherOptions,
}: {
ctx: { BodyForAgent?: string; Body?: string };
dispatcherOptions: { deliver: (payload: { text: string }) => Promise<void> };
}) {
await dispatcherOptions.deliver({
text: `qa-echo: ${String(ctx.BodyForAgent ?? ctx.Body ?? "")}`,
});
},
},
},
} as unknown as PluginRuntime;
}
describe("qa-channel plugin", () => {
it("roundtrips inbound DM traffic through the qa bus", async () => {
const state = createQaBusState();
const bus = await startQaBusServer({ state });
setQaChannelRuntime(createMockQaRuntime());
const cfg = {
channels: {
"qa-channel": {
baseUrl: bus.baseUrl,
botUserId: "openclaw",
botDisplayName: "OpenClaw QA",
allowFrom: ["*"],
},
},
};
const account = qaChannelPlugin.config.resolveAccount(cfg, "default");
const abort = new AbortController();
const startAccount = qaChannelPlugin.gateway?.startAccount;
expect(startAccount).toBeDefined();
const task = startAccount!(
createStartAccountContext({
account,
cfg,
abortSignal: abort.signal,
}),
);
try {
state.addInboundMessage({
conversation: { id: "alice", kind: "direct" },
senderId: "alice",
senderName: "Alice",
text: "hello",
});
const outbound = await state.waitFor({
kind: "message-text",
textIncludes: "qa-echo: hello",
direction: "outbound",
timeoutMs: 5_000,
});
expect("text" in outbound && outbound.text).toContain("qa-echo: hello");
} finally {
abort.abort();
await task;
await bus.stop();
}
});
it("exposes thread and message actions against the qa bus", async () => {
const state = createQaBusState();
const bus = await startQaBusServer({ state });
try {
const cfg = {
channels: {
"qa-channel": {
baseUrl: bus.baseUrl,
botUserId: "openclaw",
botDisplayName: "OpenClaw QA",
},
},
};
const handleAction = qaChannelPlugin.actions?.handleAction;
expect(handleAction).toBeDefined();
const threadResult = await handleAction!({
channel: "qa-channel",
action: "thread-create",
cfg,
accountId: "default",
params: {
channelId: "qa-room",
title: "QA thread",
},
});
const threadPayload = extractToolPayload(threadResult) as {
thread: { id: string };
target: string;
};
expect(threadPayload.thread.id).toBeTruthy();
expect(threadPayload.target).toContain(threadPayload.thread.id);
const outbound = state.addOutboundMessage({
to: threadPayload.target,
text: "message",
threadId: threadPayload.thread.id,
});
await handleAction!({
channel: "qa-channel",
action: "react",
cfg,
accountId: "default",
params: {
messageId: outbound.id,
emoji: "white_check_mark",
},
});
await handleAction!({
channel: "qa-channel",
action: "edit",
cfg,
accountId: "default",
params: {
messageId: outbound.id,
text: "message (edited)",
},
});
const readResult = await handleAction!({
channel: "qa-channel",
action: "read",
cfg,
accountId: "default",
params: {
messageId: outbound.id,
},
});
const readPayload = extractToolPayload(readResult) as { message: { text: string } };
expect(readPayload.message.text).toContain("(edited)");
const searchResult = await handleAction!({
channel: "qa-channel",
action: "search",
cfg,
accountId: "default",
params: {
query: "edited",
channelId: "qa-room",
threadId: threadPayload.thread.id,
},
});
const searchPayload = extractToolPayload(searchResult) as {
messages: Array<{ id: string }>;
};
expect(searchPayload.messages.some((message) => message.id === outbound.id)).toBe(true);
await handleAction!({
channel: "qa-channel",
action: "delete",
cfg,
accountId: "default",
params: {
messageId: outbound.id,
},
});
expect(state.readMessage({ messageId: outbound.id }).deleted).toBe(true);
} finally {
await bus.stop();
}
});
});

View File

@@ -0,0 +1,112 @@
import {
buildChannelOutboundSessionRoute,
createChatChannelPlugin,
getChatChannelMeta,
} from "openclaw/plugin-sdk/core";
import {
DEFAULT_ACCOUNT_ID,
listQaChannelAccountIds,
resolveDefaultQaChannelAccountId,
resolveQaChannelAccount,
} from "./accounts.js";
import { buildQaTarget, normalizeQaTarget, parseQaTarget } from "./bus-client.js";
import { qaChannelMessageActions } from "./channel-actions.js";
import { qaChannelPluginConfigSchema } from "./config-schema.js";
import { startQaGatewayAccount } from "./gateway.js";
import { sendQaChannelText } from "./outbound.js";
import type { ChannelPlugin } from "./runtime-api.js";
import { applyQaSetup } from "./setup.js";
import { qaChannelStatus } from "./status.js";
import type { CoreConfig, ResolvedQaChannelAccount } from "./types.js";
const CHANNEL_ID = "qa-channel" as const;
const meta = { ...getChatChannelMeta(CHANNEL_ID) };
export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createChatChannelPlugin({
base: {
id: CHANNEL_ID,
meta,
capabilities: {
chatTypes: ["direct", "group"],
},
reload: { configPrefixes: ["channels.qa-channel"] },
configSchema: qaChannelPluginConfigSchema,
setup: {
applyAccountConfig: ({ cfg, accountId, input }) =>
applyQaSetup({
cfg,
accountId,
input: input as Record<string, unknown>,
}),
},
config: {
listAccountIds: (cfg) => listQaChannelAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) =>
resolveQaChannelAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultQaChannelAccountId(cfg as CoreConfig),
isConfigured: (account) => account.configured,
resolveAllowFrom: ({ cfg, accountId }) =>
resolveQaChannelAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom,
resolveDefaultTo: ({ cfg, accountId }) =>
resolveQaChannelAccount({ cfg: cfg as CoreConfig, accountId }).config.defaultTo,
},
messaging: {
normalizeTarget: normalizeQaTarget,
parseExplicitTarget: ({ raw }) => {
const parsed = parseQaTarget(raw);
return {
to: buildQaTarget(parsed),
threadId: parsed.threadId,
chatType: parsed.chatType,
};
},
inferTargetChatType: ({ to }) => parseQaTarget(to).chatType,
targetResolver: {
looksLikeId: (raw) =>
/^((dm|channel):|thread:[^/]+\/)/i.test(raw.trim()) || raw.trim().length > 0,
hint: "<dm:user|channel:room|thread:room/thread>",
},
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target, threadId }) => {
const parsed = parseQaTarget(target);
return buildChannelOutboundSessionRoute({
cfg,
agentId,
channel: CHANNEL_ID,
accountId,
peer: {
kind: parsed.chatType === "direct" ? "direct" : "channel",
id: buildQaTarget(parsed),
},
chatType: parsed.chatType,
from: `qa-channel:${accountId ?? DEFAULT_ACCOUNT_ID}`,
to: buildQaTarget(parsed),
threadId: threadId ?? parsed.threadId,
});
},
},
status: qaChannelStatus,
gateway: {
startAccount: async (ctx) => {
await startQaGatewayAccount(CHANNEL_ID, meta.label, ctx);
},
},
actions: qaChannelMessageActions,
},
outbound: {
base: {
deliveryMode: "direct",
},
attachedResults: {
channel: CHANNEL_ID,
sendText: async ({ cfg, to, text, accountId, threadId, replyToId }) =>
await sendQaChannelText({
cfg: cfg as CoreConfig,
accountId,
to,
text,
threadId,
replyToId,
}),
},
},
});

View File

@@ -0,0 +1,32 @@
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "openclaw/plugin-sdk/zod";
const QaChannelActionConfigSchema = z
.object({
messages: z.boolean().optional(),
reactions: z.boolean().optional(),
search: z.boolean().optional(),
threads: z.boolean().optional(),
})
.strict();
export const QaChannelAccountConfigSchema = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
baseUrl: z.string().url().optional(),
botUserId: z.string().optional(),
botDisplayName: z.string().optional(),
pollTimeoutMs: z.number().int().min(100).max(30_000).optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
defaultTo: z.string().optional(),
actions: QaChannelActionConfigSchema.optional(),
})
.strict();
export const QaChannelConfigSchema = QaChannelAccountConfigSchema.extend({
accounts: z.record(z.string(), QaChannelAccountConfigSchema.partial()).optional(),
defaultAccount: z.string().optional(),
}).strict();
export const qaChannelPluginConfigSchema = buildChannelConfigSchema(QaChannelConfigSchema);

View File

@@ -0,0 +1,55 @@
import { pollQaBus } from "./bus-client.js";
import { handleQaInbound } from "./inbound.js";
import type { ChannelGatewayContext } from "./runtime-api.js";
import type { CoreConfig, ResolvedQaChannelAccount } from "./types.js";
export async function startQaGatewayAccount(
channelId: string,
channelLabel: string,
ctx: ChannelGatewayContext<ResolvedQaChannelAccount>,
) {
const account = ctx.account;
if (!account.configured) {
throw new Error(`QA channel is not configured for account "${account.accountId}"`);
}
ctx.setStatus({
accountId: account.accountId,
running: true,
configured: true,
enabled: account.enabled,
baseUrl: account.baseUrl,
});
let cursor = 0;
try {
while (!ctx.abortSignal.aborted) {
const result = await pollQaBus({
baseUrl: account.baseUrl,
accountId: account.accountId,
cursor,
timeoutMs: account.pollTimeoutMs,
signal: ctx.abortSignal,
});
cursor = result.cursor;
for (const event of result.events) {
if (event.kind !== "inbound-message") {
continue;
}
await handleQaInbound({
channelId,
channelLabel,
account,
config: ctx.cfg as CoreConfig,
message: event.message,
});
}
}
} catch (error) {
if (!(error instanceof Error) || error.name !== "AbortError") {
throw error;
}
}
ctx.setStatus({
accountId: account.accountId,
running: false,
});
}

View File

@@ -0,0 +1,124 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { buildQaTarget, sendQaBusMessage, type QaBusMessage } from "./bus-client.js";
import { getQaChannelRuntime } from "./runtime.js";
import type { CoreConfig, ResolvedQaChannelAccount } from "./types.js";
export async function handleQaInbound(params: {
channelId: string;
channelLabel: string;
account: ResolvedQaChannelAccount;
config: CoreConfig;
message: QaBusMessage;
}) {
const runtime = getQaChannelRuntime();
const inbound = params.message;
const target = buildQaTarget({
chatType: inbound.conversation.kind,
conversationId: inbound.conversation.id,
threadId: inbound.threadId,
});
const route = runtime.channel.routing.resolveAgentRoute({
cfg: params.config as OpenClawConfig,
channel: params.channelId,
accountId: params.account.accountId,
peer: {
kind: inbound.conversation.kind === "direct" ? "direct" : "channel",
id: target,
},
});
const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, {
agentId: route.agentId,
});
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = runtime.channel.reply.formatAgentEnvelope({
channel: params.channelLabel,
from: inbound.senderName || inbound.senderId,
timestamp: inbound.timestamp,
previousTimestamp,
envelope: runtime.channel.reply.resolveEnvelopeFormatOptions(params.config as OpenClawConfig),
body: inbound.text,
});
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: inbound.text,
RawBody: inbound.text,
CommandBody: inbound.text,
From: buildQaTarget({
chatType: inbound.conversation.kind,
conversationId: inbound.senderId,
}),
To: target,
SessionKey: route.sessionKey,
AccountId: route.accountId ?? params.account.accountId,
ChatType: inbound.conversation.kind === "direct" ? "direct" : "group",
ConversationLabel:
inbound.threadTitle ||
inbound.conversation.title ||
inbound.senderName ||
inbound.conversation.id,
GroupSubject:
inbound.conversation.kind === "channel"
? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id
: undefined,
GroupChannel: inbound.conversation.kind === "channel" ? inbound.conversation.id : undefined,
NativeChannelId: inbound.conversation.id,
MessageThreadId: inbound.threadId,
ThreadLabel: inbound.threadTitle,
ThreadParentId: inbound.threadId ? inbound.conversation.id : undefined,
SenderName: inbound.senderName,
SenderId: inbound.senderId,
Provider: params.channelId,
Surface: params.channelId,
MessageSid: inbound.id,
MessageSidFull: inbound.id,
ReplyToId: inbound.replyToId,
Timestamp: inbound.timestamp,
OriginatingChannel: params.channelId,
OriginatingTo: target,
CommandAuthorized: true,
});
await dispatchInboundReplyWithBase({
cfg: params.config as OpenClawConfig,
channel: params.channelId,
accountId: params.account.accountId,
route,
storePath,
ctxPayload,
core: runtime,
deliver: async (payload) => {
const text =
payload && typeof payload === "object" && "text" in payload
? String((payload as { text?: string }).text ?? "")
: "";
if (!text.trim()) {
return;
}
await sendQaBusMessage({
baseUrl: params.account.baseUrl,
accountId: params.account.accountId,
to: target,
text,
senderId: params.account.botUserId,
senderName: params.account.botDisplayName,
threadId: inbound.threadId,
replyToId: inbound.id,
});
},
onRecordError: (error) => {
throw error instanceof Error
? error
: new Error(`qa-channel session record failed: ${String(error)}`);
},
onDispatchError: (error) => {
throw error instanceof Error
? error
: new Error(`qa-channel dispatch failed: ${String(error)}`);
},
});
}

View File

@@ -0,0 +1,34 @@
import { resolveQaChannelAccount } from "./accounts.js";
import { buildQaTarget, parseQaTarget, sendQaBusMessage } from "./bus-client.js";
import type { CoreConfig } from "./types.js";
export async function sendQaChannelText(params: {
cfg: CoreConfig;
accountId?: string | null;
to: string;
text: string;
threadId?: string | number | null;
replyToId?: string | number | null;
}) {
const account = resolveQaChannelAccount({ cfg: params.cfg, accountId: params.accountId });
const parsed = parseQaTarget(params.to);
const resolvedThreadId = params.threadId == null ? parsed.threadId : String(params.threadId);
const { message } = await sendQaBusMessage({
baseUrl: account.baseUrl,
accountId: account.accountId,
to: buildQaTarget({
chatType: parsed.chatType,
conversationId: parsed.conversationId,
threadId: resolvedThreadId,
}),
text: params.text,
senderId: account.botUserId,
senderName: account.botDisplayName,
threadId: resolvedThreadId,
replyToId: params.replyToId == null ? undefined : String(params.replyToId),
});
return {
to: params.to,
messageId: message.id,
};
}

View File

@@ -0,0 +1,180 @@
export type QaBusConversationKind = "direct" | "channel";
export type QaBusConversation = {
id: string;
kind: QaBusConversationKind;
title?: string;
};
export type QaBusMessage = {
id: string;
accountId: string;
direction: "inbound" | "outbound";
conversation: QaBusConversation;
senderId: string;
senderName?: string;
text: string;
timestamp: number;
threadId?: string;
threadTitle?: string;
replyToId?: string;
deleted?: boolean;
editedAt?: number;
reactions: Array<{
emoji: string;
senderId: string;
timestamp: number;
}>;
};
export type QaBusThread = {
id: string;
accountId: string;
conversationId: string;
title: string;
createdAt: number;
createdBy: string;
};
export type QaBusEvent =
| {
cursor: number;
kind: "inbound-message";
accountId: string;
message: QaBusMessage;
}
| {
cursor: number;
kind: "outbound-message";
accountId: string;
message: QaBusMessage;
}
| {
cursor: number;
kind: "thread-created";
accountId: string;
thread: QaBusThread;
}
| {
cursor: number;
kind: "message-edited";
accountId: string;
message: QaBusMessage;
}
| {
cursor: number;
kind: "message-deleted";
accountId: string;
message: QaBusMessage;
}
| {
cursor: number;
kind: "reaction-added";
accountId: string;
message: QaBusMessage;
emoji: string;
senderId: string;
};
export type QaBusInboundMessageInput = {
accountId?: string;
conversation: QaBusConversation;
senderId: string;
senderName?: string;
text: string;
timestamp?: number;
threadId?: string;
threadTitle?: string;
replyToId?: string;
};
export type QaBusOutboundMessageInput = {
accountId?: string;
to: string;
senderId?: string;
senderName?: string;
text: string;
timestamp?: number;
threadId?: string;
replyToId?: string;
};
export type QaBusCreateThreadInput = {
accountId?: string;
conversationId: string;
title: string;
createdBy?: string;
timestamp?: number;
};
export type QaBusReactToMessageInput = {
accountId?: string;
messageId: string;
emoji: string;
senderId?: string;
timestamp?: number;
};
export type QaBusEditMessageInput = {
accountId?: string;
messageId: string;
text: string;
timestamp?: number;
};
export type QaBusDeleteMessageInput = {
accountId?: string;
messageId: string;
timestamp?: number;
};
export type QaBusSearchMessagesInput = {
accountId?: string;
query?: string;
conversationId?: string;
threadId?: string;
limit?: number;
};
export type QaBusReadMessageInput = {
accountId?: string;
messageId: string;
};
export type QaBusPollInput = {
accountId?: string;
cursor?: number;
timeoutMs?: number;
limit?: number;
};
export type QaBusPollResult = {
cursor: number;
events: QaBusEvent[];
};
export type QaBusStateSnapshot = {
cursor: number;
conversations: QaBusConversation[];
threads: QaBusThread[];
messages: QaBusMessage[];
events: QaBusEvent[];
};
export type QaBusWaitForInput =
| {
timeoutMs?: number;
kind: "event-kind";
eventKind: QaBusEvent["kind"];
}
| {
timeoutMs?: number;
kind: "message-text";
textIncludes: string;
direction?: QaBusMessage["direction"];
}
| {
timeoutMs?: number;
kind: "thread-id";
threadId: string;
};

View File

@@ -0,0 +1,23 @@
export type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-contract";
export type { PluginRuntime } from "openclaw/plugin-sdk/core";
export type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type { ChannelPlugin } from "openclaw/plugin-sdk/core";
export {
buildChannelConfigSchema,
buildChannelOutboundSessionRoute,
createChatChannelPlugin,
defineChannelPluginEntry,
getChatChannelMeta,
jsonResult,
readStringParam,
} from "openclaw/plugin-sdk/core";
export {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
export { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
export { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch";

View File

@@ -0,0 +1,7 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "./runtime-api.js";
const { setRuntime: setQaChannelRuntime, getRuntime: getQaChannelRuntime } =
createPluginRuntimeStore<PluginRuntime>("QA channel runtime not initialized");
export { getQaChannelRuntime, setQaChannelRuntime };

View File

@@ -0,0 +1,40 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { DEFAULT_ACCOUNT_ID } from "./accounts.js";
import type { CoreConfig } from "./types.js";
export function applyQaSetup(params: {
cfg: OpenClawConfig;
accountId: string;
input: Record<string, unknown>;
}): OpenClawConfig {
const nextCfg = structuredClone(params.cfg) as CoreConfig;
const section = nextCfg.channels?.["qa-channel"] ?? {};
const accounts = { ...(section.accounts ?? {}) };
const target =
params.accountId === DEFAULT_ACCOUNT_ID
? { ...section }
: { ...(accounts[params.accountId] ?? {}) };
if (typeof params.input.baseUrl === "string") {
target.baseUrl = params.input.baseUrl;
}
if (typeof params.input.botUserId === "string") {
target.botUserId = params.input.botUserId;
}
if (typeof params.input.botDisplayName === "string") {
target.botDisplayName = params.input.botDisplayName;
}
nextCfg.channels ??= {};
if (params.accountId === DEFAULT_ACCOUNT_ID) {
nextCfg.channels["qa-channel"] = {
...section,
...target,
};
} else {
accounts[params.accountId] = target;
nextCfg.channels["qa-channel"] = {
...section,
accounts,
};
}
return nextCfg as OpenClawConfig;
}

View File

@@ -0,0 +1,23 @@
import { DEFAULT_ACCOUNT_ID } from "./accounts.js";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "./runtime-api.js";
import type { ResolvedQaChannelAccount } from "./types.js";
export const qaChannelStatus = createComputedAccountStatusAdapter<ResolvedQaChannelAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
buildChannelSummary: ({ snapshot }) => ({
baseUrl: snapshot.baseUrl ?? "[missing]",
}),
resolveAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
extra: {
baseUrl: account.baseUrl || "[missing]",
botUserId: account.botUserId,
},
}),
});

View File

@@ -0,0 +1,44 @@
export type QaChannelActionConfig = {
messages?: boolean;
reactions?: boolean;
search?: boolean;
threads?: boolean;
};
export type QaChannelAccountConfig = {
name?: string;
enabled?: boolean;
baseUrl?: string;
botUserId?: string;
botDisplayName?: string;
pollTimeoutMs?: number;
allowFrom?: Array<string | number>;
defaultTo?: string;
actions?: QaChannelActionConfig;
};
export type QaChannelConfig = QaChannelAccountConfig & {
accounts?: Record<string, Partial<QaChannelAccountConfig>>;
defaultAccount?: string;
};
export type CoreConfig = {
channels?: {
"qa-channel"?: QaChannelConfig;
};
session?: {
store?: string;
};
};
export type ResolvedQaChannelAccount = {
accountId: string;
enabled: boolean;
configured: boolean;
name?: string;
baseUrl: string;
botUserId: string;
botDisplayName: string;
pollTimeoutMs: number;
config: QaChannelAccountConfig;
};

View File

@@ -0,0 +1,2 @@
export * from "./src/protocol.js";
export * from "./src/bus-client.js";

10
extensions/qa-lab/api.ts Normal file
View File

@@ -0,0 +1,10 @@
export * from "./src/bus-queries.js";
export * from "./src/bus-server.js";
export * from "./src/bus-state.js";
export * from "./src/bus-waiters.js";
export * from "./src/harness-runtime.js";
export * from "./src/lab-server.js";
export * from "./src/report.js";
export * from "./src/scenario.js";
export * from "./src/self-check-scenario.js";
export * from "./src/self-check.js";

View File

@@ -0,0 +1,24 @@
import { definePluginEntry } from "./runtime-api.js";
import { registerQaLabCli } from "./src/cli.js";
export default definePluginEntry({
id: "qa-lab",
name: "QA Lab",
description: "Private QA automation harness and debugger UI",
register(api) {
api.registerCli(
async ({ program }) => {
registerQaLabCli(program);
},
{
descriptors: [
{
name: "qa",
description: "Run QA scenarios and launch the private QA debugger UI",
hasSubcommands: true,
},
],
},
);
},
});

View File

@@ -0,0 +1,8 @@
{
"id": "qa-lab",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,31 @@
{
"name": "@openclaw/qa-lab",
"version": "2026.4.4",
"private": true,
"description": "OpenClaw QA lab plugin with private debugger UI and scenario runner",
"type": "module",
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.4"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"npmSpec": "@openclaw/qa-lab",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.4"
},
"compat": {
"pluginApi": ">=2026.4.4"
}
}
}

View File

@@ -0,0 +1 @@
export * from "./src/runtime-api.js";

View File

@@ -0,0 +1,137 @@
import type {
QaBusConversation,
QaBusEvent,
QaBusMessage,
QaBusPollInput,
QaBusPollResult,
QaBusReadMessageInput,
QaBusSearchMessagesInput,
QaBusStateSnapshot,
QaBusThread,
} from "./runtime-api.js";
export const DEFAULT_ACCOUNT_ID = "default";
export function normalizeAccountId(raw?: string): string {
const trimmed = raw?.trim();
return trimmed || DEFAULT_ACCOUNT_ID;
}
export function normalizeConversationFromTarget(target: string): {
conversation: QaBusConversation;
threadId?: string;
} {
const trimmed = target.trim();
if (trimmed.startsWith("thread:")) {
const rest = trimmed.slice("thread:".length);
const slash = rest.indexOf("/");
if (slash > 0) {
return {
conversation: { id: rest.slice(0, slash), kind: "channel" },
threadId: rest.slice(slash + 1),
};
}
}
if (trimmed.startsWith("channel:")) {
return {
conversation: { id: trimmed.slice("channel:".length), kind: "channel" },
};
}
if (trimmed.startsWith("dm:")) {
return {
conversation: { id: trimmed.slice("dm:".length), kind: "direct" },
};
}
return {
conversation: { id: trimmed, kind: "direct" },
};
}
export function cloneMessage(message: QaBusMessage): QaBusMessage {
return {
...message,
conversation: { ...message.conversation },
reactions: message.reactions.map((reaction) => ({ ...reaction })),
};
}
export function cloneEvent(event: QaBusEvent): QaBusEvent {
switch (event.kind) {
case "inbound-message":
case "outbound-message":
case "message-edited":
case "message-deleted":
case "reaction-added":
return { ...event, message: cloneMessage(event.message) };
case "thread-created":
return { ...event, thread: { ...event.thread } };
}
}
export function buildQaBusSnapshot(params: {
cursor: number;
conversations: Map<string, QaBusConversation>;
threads: Map<string, QaBusThread>;
messages: Map<string, QaBusMessage>;
events: QaBusEvent[];
}): QaBusStateSnapshot {
return {
cursor: params.cursor,
conversations: Array.from(params.conversations.values()).map((conversation) => ({
...conversation,
})),
threads: Array.from(params.threads.values()).map((thread) => ({ ...thread })),
messages: Array.from(params.messages.values()).map((message) => cloneMessage(message)),
events: params.events.map((event) => cloneEvent(event)),
};
}
export function readQaBusMessage(params: {
messages: Map<string, QaBusMessage>;
input: QaBusReadMessageInput;
}) {
const message = params.messages.get(params.input.messageId);
if (!message) {
throw new Error(`qa-bus message not found: ${params.input.messageId}`);
}
return cloneMessage(message);
}
export function searchQaBusMessages(params: {
messages: Map<string, QaBusMessage>;
input: QaBusSearchMessagesInput;
}) {
const accountId = normalizeAccountId(params.input.accountId);
const limit = Math.max(1, Math.min(params.input.limit ?? 20, 100));
const query = params.input.query?.trim().toLowerCase();
return Array.from(params.messages.values())
.filter((message) => message.accountId === accountId)
.filter((message) =>
params.input.conversationId ? message.conversation.id === params.input.conversationId : true,
)
.filter((message) =>
params.input.threadId ? message.threadId === params.input.threadId : true,
)
.filter((message) => (query ? message.text.toLowerCase().includes(query) : true))
.slice(-limit)
.map((message) => cloneMessage(message));
}
export function pollQaBusEvents(params: {
events: QaBusEvent[];
cursor: number;
input?: QaBusPollInput;
}): QaBusPollResult {
const accountId = normalizeAccountId(params.input?.accountId);
const startCursor = params.input?.cursor ?? 0;
const effectiveStartCursor = params.cursor < startCursor ? 0 : startCursor;
const limit = Math.max(1, Math.min(params.input?.limit ?? 100, 500));
const matches = params.events
.filter((event) => event.accountId === accountId && event.cursor > effectiveStartCursor)
.slice(0, limit)
.map((event) => cloneEvent(event));
return {
cursor: params.cursor,
events: matches,
};
}

View File

@@ -0,0 +1,179 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { QaBusState } from "./bus-state.js";
import type {
QaBusCreateThreadInput,
QaBusDeleteMessageInput,
QaBusEditMessageInput,
QaBusInboundMessageInput,
QaBusOutboundMessageInput,
QaBusPollInput,
QaBusReactToMessageInput,
QaBusReadMessageInput,
QaBusSearchMessagesInput,
QaBusWaitForInput,
} from "./runtime-api.js";
async function readJson(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const text = Buffer.concat(chunks).toString("utf8").trim();
return text ? (JSON.parse(text) as unknown) : {};
}
export function writeJson(res: ServerResponse, statusCode: number, body: unknown) {
const payload = JSON.stringify(body);
res.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload),
});
res.end(payload);
}
export function writeError(res: ServerResponse, statusCode: number, error: unknown) {
writeJson(res, statusCode, {
error: error instanceof Error ? error.message : String(error),
});
}
export async function handleQaBusRequest(params: {
req: IncomingMessage;
res: ServerResponse;
state: QaBusState;
}): Promise<boolean> {
const method = params.req.method ?? "GET";
const url = new URL(params.req.url ?? "/", "http://127.0.0.1");
if (method === "GET" && url.pathname === "/health") {
writeJson(params.res, 200, { ok: true });
return true;
}
if (method === "GET" && url.pathname === "/v1/state") {
writeJson(params.res, 200, params.state.getSnapshot());
return true;
}
if (!url.pathname.startsWith("/v1/")) {
return false;
}
if (method !== "POST") {
writeError(params.res, 405, "method not allowed");
return true;
}
const body = (await readJson(params.req)) as Record<string, unknown>;
try {
switch (url.pathname) {
case "/v1/reset":
params.state.reset();
writeJson(params.res, 200, { ok: true });
return true;
case "/v1/inbound/message":
writeJson(params.res, 200, {
message: params.state.addInboundMessage(body as unknown as QaBusInboundMessageInput),
});
return true;
case "/v1/outbound/message":
writeJson(params.res, 200, {
message: params.state.addOutboundMessage(body as unknown as QaBusOutboundMessageInput),
});
return true;
case "/v1/actions/thread-create":
writeJson(params.res, 200, {
thread: params.state.createThread(body as unknown as QaBusCreateThreadInput),
});
return true;
case "/v1/actions/react":
writeJson(params.res, 200, {
message: params.state.reactToMessage(body as unknown as QaBusReactToMessageInput),
});
return true;
case "/v1/actions/edit":
writeJson(params.res, 200, {
message: params.state.editMessage(body as unknown as QaBusEditMessageInput),
});
return true;
case "/v1/actions/delete":
writeJson(params.res, 200, {
message: params.state.deleteMessage(body as unknown as QaBusDeleteMessageInput),
});
return true;
case "/v1/actions/read":
writeJson(params.res, 200, {
message: params.state.readMessage(body as unknown as QaBusReadMessageInput),
});
return true;
case "/v1/actions/search":
writeJson(params.res, 200, {
messages: params.state.searchMessages(body as unknown as QaBusSearchMessagesInput),
});
return true;
case "/v1/poll": {
const input = body as unknown as QaBusPollInput;
const timeoutMs = Math.max(0, Math.min(input.timeoutMs ?? 0, 30_000));
const initial = params.state.poll(input);
if (initial.events.length > 0 || timeoutMs === 0) {
writeJson(params.res, 200, initial);
return true;
}
try {
await params.state.waitFor({
kind: "event-kind",
eventKind: "inbound-message",
timeoutMs,
});
} catch {
// timeout ok for long-poll
}
writeJson(params.res, 200, params.state.poll(input));
return true;
}
case "/v1/wait":
writeJson(params.res, 200, {
match: await params.state.waitFor(body as unknown as QaBusWaitForInput),
});
return true;
default:
writeError(params.res, 404, "not found");
return true;
}
} catch (error) {
writeError(params.res, 400, error);
return true;
}
}
export function createQaBusServer(state: QaBusState): Server {
return createServer(async (req, res) => {
const handled = await handleQaBusRequest({ req, res, state });
if (!handled) {
writeError(res, 404, "not found");
}
});
}
export async function startQaBusServer(params: { state: QaBusState; port?: number }) {
const server = createQaBusServer(params.state);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(params.port ?? 0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("qa-bus failed to bind");
}
return {
server,
port: address.port,
baseUrl: `http://127.0.0.1:${address.port}`,
async stop() {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
},
};
}

View File

@@ -0,0 +1,257 @@
import { randomUUID } from "node:crypto";
import {
buildQaBusSnapshot,
cloneMessage,
normalizeAccountId,
normalizeConversationFromTarget,
pollQaBusEvents,
readQaBusMessage,
searchQaBusMessages,
} from "./bus-queries.js";
import { createQaBusWaiterStore } from "./bus-waiters.js";
import type {
QaBusConversation,
QaBusCreateThreadInput,
QaBusDeleteMessageInput,
QaBusEditMessageInput,
QaBusEvent,
QaBusInboundMessageInput,
QaBusMessage,
QaBusOutboundMessageInput,
QaBusPollInput,
QaBusReadMessageInput,
QaBusReactToMessageInput,
QaBusSearchMessagesInput,
QaBusThread,
QaBusWaitForInput,
} from "./runtime-api.js";
const DEFAULT_BOT_ID = "openclaw";
const DEFAULT_BOT_NAME = "OpenClaw QA";
type QaBusEventSeed =
| Omit<Extract<QaBusEvent, { kind: "inbound-message" }>, "cursor">
| Omit<Extract<QaBusEvent, { kind: "outbound-message" }>, "cursor">
| Omit<Extract<QaBusEvent, { kind: "thread-created" }>, "cursor">
| Omit<Extract<QaBusEvent, { kind: "message-edited" }>, "cursor">
| Omit<Extract<QaBusEvent, { kind: "message-deleted" }>, "cursor">
| Omit<Extract<QaBusEvent, { kind: "reaction-added" }>, "cursor">;
export function createQaBusState() {
const conversations = new Map<string, QaBusConversation>();
const threads = new Map<string, QaBusThread>();
const messages = new Map<string, QaBusMessage>();
const events: QaBusEvent[] = [];
let cursor = 0;
const waiters = createQaBusWaiterStore(() =>
buildQaBusSnapshot({
cursor,
conversations,
threads,
messages,
events,
}),
);
const pushEvent = (event: QaBusEventSeed | ((cursor: number) => QaBusEventSeed)): QaBusEvent => {
cursor += 1;
const next = typeof event === "function" ? event(cursor) : event;
const finalized = { cursor, ...next } as QaBusEvent;
events.push(finalized);
waiters.settle();
return finalized;
};
const ensureConversation = (conversation: QaBusConversation): QaBusConversation => {
const existing = conversations.get(conversation.id);
if (existing) {
if (!existing.title && conversation.title) {
existing.title = conversation.title;
}
return existing;
}
const created = { ...conversation };
conversations.set(created.id, created);
return created;
};
const createMessage = (params: {
direction: QaBusMessage["direction"];
accountId: string;
conversation: QaBusConversation;
senderId: string;
senderName?: string;
text: string;
timestamp?: number;
threadId?: string;
threadTitle?: string;
replyToId?: string;
}): QaBusMessage => {
const conversation = ensureConversation(params.conversation);
const message: QaBusMessage = {
id: randomUUID(),
accountId: params.accountId,
direction: params.direction,
conversation,
senderId: params.senderId,
senderName: params.senderName,
text: params.text,
timestamp: params.timestamp ?? Date.now(),
threadId: params.threadId,
threadTitle: params.threadTitle,
replyToId: params.replyToId,
reactions: [],
};
messages.set(message.id, message);
return message;
};
return {
reset() {
conversations.clear();
threads.clear();
messages.clear();
events.length = 0;
// Keep the cursor monotonic across resets so long-poll clients do not
// miss fresh events after the bus is cleared mid-session.
waiters.reset();
},
getSnapshot() {
return buildQaBusSnapshot({
cursor,
conversations,
threads,
messages,
events,
});
},
addInboundMessage(input: QaBusInboundMessageInput) {
const accountId = normalizeAccountId(input.accountId);
const message = createMessage({
direction: "inbound",
accountId,
conversation: input.conversation,
senderId: input.senderId,
senderName: input.senderName,
text: input.text,
timestamp: input.timestamp,
threadId: input.threadId,
threadTitle: input.threadTitle,
replyToId: input.replyToId,
});
pushEvent({
kind: "inbound-message",
accountId,
message: cloneMessage(message),
});
return cloneMessage(message);
},
addOutboundMessage(input: QaBusOutboundMessageInput) {
const accountId = normalizeAccountId(input.accountId);
const { conversation, threadId } = normalizeConversationFromTarget(input.to);
const message = createMessage({
direction: "outbound",
accountId,
conversation,
senderId: input.senderId?.trim() || DEFAULT_BOT_ID,
senderName: input.senderName?.trim() || DEFAULT_BOT_NAME,
text: input.text,
timestamp: input.timestamp,
threadId: input.threadId ?? threadId,
replyToId: input.replyToId,
});
pushEvent({
kind: "outbound-message",
accountId,
message: cloneMessage(message),
});
return cloneMessage(message);
},
createThread(input: QaBusCreateThreadInput) {
const accountId = normalizeAccountId(input.accountId);
const thread: QaBusThread = {
id: `thread-${randomUUID()}`,
accountId,
conversationId: input.conversationId,
title: input.title,
createdAt: input.timestamp ?? Date.now(),
createdBy: input.createdBy?.trim() || DEFAULT_BOT_ID,
};
threads.set(thread.id, thread);
ensureConversation({
id: input.conversationId,
kind: "channel",
});
pushEvent({
kind: "thread-created",
accountId,
thread: { ...thread },
});
return { ...thread };
},
reactToMessage(input: QaBusReactToMessageInput) {
const accountId = normalizeAccountId(input.accountId);
const message = messages.get(input.messageId);
if (!message) {
throw new Error(`qa-bus message not found: ${input.messageId}`);
}
const reaction = {
emoji: input.emoji,
senderId: input.senderId?.trim() || DEFAULT_BOT_ID,
timestamp: input.timestamp ?? Date.now(),
};
message.reactions.push(reaction);
pushEvent({
kind: "reaction-added",
accountId,
message: cloneMessage(message),
emoji: reaction.emoji,
senderId: reaction.senderId,
});
return cloneMessage(message);
},
editMessage(input: QaBusEditMessageInput) {
const accountId = normalizeAccountId(input.accountId);
const message = messages.get(input.messageId);
if (!message) {
throw new Error(`qa-bus message not found: ${input.messageId}`);
}
message.text = input.text;
message.editedAt = input.timestamp ?? Date.now();
pushEvent({
kind: "message-edited",
accountId,
message: cloneMessage(message),
});
return cloneMessage(message);
},
deleteMessage(input: QaBusDeleteMessageInput) {
const accountId = normalizeAccountId(input.accountId);
const message = messages.get(input.messageId);
if (!message) {
throw new Error(`qa-bus message not found: ${input.messageId}`);
}
message.deleted = true;
pushEvent({
kind: "message-deleted",
accountId,
message: cloneMessage(message),
});
return cloneMessage(message);
},
readMessage(input: QaBusReadMessageInput) {
return readQaBusMessage({ messages, input });
},
searchMessages(input: QaBusSearchMessagesInput) {
return searchQaBusMessages({ messages, input });
},
poll(input: QaBusPollInput = {}) {
return pollQaBusEvents({ events, cursor, input });
},
async waitFor(input: QaBusWaitForInput) {
return await waiters.waitFor(input);
},
};
}
export type QaBusState = ReturnType<typeof createQaBusState>;

View File

@@ -0,0 +1,87 @@
import type {
QaBusEvent,
QaBusMessage,
QaBusStateSnapshot,
QaBusThread,
QaBusWaitForInput,
} from "./runtime-api.js";
export const DEFAULT_WAIT_TIMEOUT_MS = 5_000;
export type QaBusWaitMatch = QaBusEvent | QaBusMessage | QaBusThread;
type Waiter = {
resolve: (event: QaBusWaitMatch) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
matcher: (snapshot: QaBusStateSnapshot) => QaBusWaitMatch | null;
};
function createQaBusMatcher(
input: QaBusWaitForInput,
): (snapshot: QaBusStateSnapshot) => QaBusWaitMatch | null {
return (snapshot) => {
if (input.kind === "event-kind") {
return snapshot.events.find((event) => event.kind === input.eventKind) ?? null;
}
if (input.kind === "thread-id") {
return snapshot.threads.find((thread) => thread.id === input.threadId) ?? null;
}
return (
snapshot.messages.find(
(message) =>
(!input.direction || message.direction === input.direction) &&
message.text.includes(input.textIncludes),
) ?? null
);
};
}
export function createQaBusWaiterStore(getSnapshot: () => QaBusStateSnapshot) {
const waiters = new Set<Waiter>();
return {
reset(reason = "qa-bus reset") {
for (const waiter of waiters) {
clearTimeout(waiter.timer);
waiter.reject(new Error(reason));
}
waiters.clear();
},
settle() {
if (waiters.size === 0) {
return;
}
const snapshot = getSnapshot();
for (const waiter of Array.from(waiters)) {
const match = waiter.matcher(snapshot);
if (!match) {
continue;
}
clearTimeout(waiter.timer);
waiters.delete(waiter);
waiter.resolve(match);
}
},
async waitFor(input: QaBusWaitForInput) {
const matcher = createQaBusMatcher(input);
const immediate = matcher(getSnapshot());
if (immediate) {
return immediate;
}
return await new Promise<QaBusWaitMatch>((resolve, reject) => {
const timeoutMs = input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
const waiter: Waiter = {
resolve,
reject,
matcher,
timer: setTimeout(() => {
waiters.delete(waiter);
reject(new Error(`qa-bus wait timeout after ${timeoutMs}ms`));
}, timeoutMs),
};
waiters.add(waiter);
});
},
};
}

View File

@@ -0,0 +1,37 @@
import { startQaLabServer } from "./lab-server.js";
export async function runQaLabSelfCheckCommand(opts: { output?: string }) {
const server = await startQaLabServer({
outputPath: opts.output,
});
try {
const result = await server.runSelfCheck();
process.stdout.write(`QA self-check report: ${result.outputPath}\n`);
} finally {
await server.stop();
}
}
export async function runQaLabUiCommand(opts: { host?: string; port?: number }) {
const server = await startQaLabServer({
host: opts.host,
port: Number.isFinite(opts.port) ? opts.port : undefined,
});
process.stdout.write(`QA Lab UI: ${server.baseUrl}\n`);
process.stdout.write("Press Ctrl+C to stop.\n");
const shutdown = async () => {
process.off("SIGINT", onSignal);
process.off("SIGTERM", onSignal);
await server.stop();
process.exit(0);
};
const onSignal = () => {
void shutdown();
};
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);
await new Promise(() => undefined);
}

View File

@@ -0,0 +1,41 @@
import type { Command } from "commander";
type QaLabCliRuntime = typeof import("./cli.runtime.js");
let qaLabCliRuntimePromise: Promise<QaLabCliRuntime> | null = null;
async function loadQaLabCliRuntime(): Promise<QaLabCliRuntime> {
qaLabCliRuntimePromise ??= import("./cli.runtime.js");
return await qaLabCliRuntimePromise;
}
async function runQaSelfCheck(opts: { output?: string }) {
const runtime = await loadQaLabCliRuntime();
await runtime.runQaLabSelfCheckCommand(opts);
}
async function runQaUi(opts: { host?: string; port?: number }) {
const runtime = await loadQaLabCliRuntime();
await runtime.runQaLabUiCommand(opts);
}
export function registerQaLabCli(program: Command) {
const qa = program
.command("qa")
.description("Run private QA automation flows and launch the QA debugger");
qa.command("run")
.description("Run the bundled QA self-check and write a Markdown report")
.option("--output <path>", "Report output path")
.action(async (opts: { output?: string }) => {
await runQaSelfCheck(opts);
});
qa.command("ui")
.description("Start the private QA debugger UI and local QA bus")
.option("--host <host>", "Bind host", "127.0.0.1")
.option("--port <port>", "Bind port", (value: string) => Number(value))
.action(async (opts: { host?: string; port?: number }) => {
await runQaUi(opts);
});
}

View File

@@ -0,0 +1,31 @@
type ResultWithDetails = {
details?: unknown;
content?: unknown;
};
export function extractQaToolPayload(result: ResultWithDetails | null | undefined): unknown {
if (!result) {
return undefined;
}
if (result.details !== undefined) {
return result.details;
}
const textBlock = Array.isArray(result.content)
? result.content.find(
(block) =>
block &&
typeof block === "object" &&
(block as { type?: unknown }).type === "text" &&
typeof (block as { text?: unknown }).text === "string",
)
: undefined;
const text = (textBlock as { text?: string } | undefined)?.text;
if (!text) {
return result.content ?? result;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}

View File

@@ -0,0 +1,75 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
type SessionRecord = {
sessionKey: string;
body: string;
};
export function createQaRunnerRuntime(): PluginRuntime {
const sessions = new Map<string, SessionRecord>();
return {
channel: {
routing: {
resolveAgentRoute({
accountId,
peer,
}: {
accountId?: string | null;
peer?: { kind?: string; id?: string } | null;
}) {
return {
agentId: "qa-agent",
accountId: accountId ?? "default",
sessionKey: `qa-agent:${peer?.kind ?? "direct"}:${peer?.id ?? "default"}`,
mainSessionKey: "qa-agent:main",
lastRoutePolicy: "session",
matchedBy: "default",
channel: "qa-channel",
};
},
},
session: {
resolveStorePath(_store: string | undefined, { agentId }: { agentId: string }) {
return agentId;
},
readSessionUpdatedAt({ sessionKey }: { sessionKey: string }) {
return sessions.has(sessionKey) ? Date.now() : undefined;
},
recordInboundSession({
sessionKey,
ctx,
}: {
sessionKey: string;
ctx: { BodyForAgent?: string; Body?: string };
}) {
sessions.set(sessionKey, {
sessionKey,
body: String(ctx.BodyForAgent ?? ctx.Body ?? ""),
});
},
},
reply: {
resolveEnvelopeFormatOptions() {
return {};
},
formatAgentEnvelope({ body }: { body: string }) {
return body;
},
finalizeInboundContext(ctx: Record<string, unknown>) {
return ctx as typeof ctx & { CommandAuthorized: boolean };
},
async dispatchReplyWithBufferedBlockDispatcher({
ctx,
dispatcherOptions,
}: {
ctx: { BodyForAgent?: string; Body?: string };
dispatcherOptions: { deliver: (payload: { text: string }) => Promise<void> };
}) {
await dispatcherOptions.deliver({
text: `qa-echo: ${String(ctx.BodyForAgent ?? ctx.Body ?? "")}`,
});
},
},
},
} as unknown as PluginRuntime;
}

View File

@@ -0,0 +1,67 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { startQaLabServer } from "./lab-server.js";
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
while (cleanups.length > 0) {
await cleanups.pop()?.();
}
});
describe("qa-lab server", () => {
it("serves bootstrap state and writes a self-check report", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-test-"));
cleanups.push(async () => {
await rm(tempDir, { recursive: true, force: true });
});
const outputPath = path.join(tempDir, "self-check.md");
const lab = await startQaLabServer({
host: "127.0.0.1",
port: 0,
outputPath,
});
cleanups.push(async () => {
await lab.stop();
});
const bootstrapResponse = await fetch(`${lab.baseUrl}/api/bootstrap`);
expect(bootstrapResponse.status).toBe(200);
const bootstrap = (await bootstrapResponse.json()) as {
defaults: { conversationId: string; senderId: string };
};
expect(bootstrap.defaults.conversationId).toBe("alice");
expect(bootstrap.defaults.senderId).toBe("alice");
const messageResponse = await fetch(`${lab.baseUrl}/api/inbound/message`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
conversation: { id: "bob", kind: "direct" },
senderId: "bob",
senderName: "Bob",
text: "hello from test",
}),
});
expect(messageResponse.status).toBe(200);
const stateResponse = await fetch(`${lab.baseUrl}/api/state`);
expect(stateResponse.status).toBe(200);
const snapshot = (await stateResponse.json()) as {
messages: Array<{ direction: string; text: string }>;
};
expect(snapshot.messages.some((message) => message.text === "hello from test")).toBe(true);
const result = await lab.runSelfCheck();
expect(result.scenarioResult.status).toBe("pass");
const markdown = await readFile(outputPath, "utf8");
expect(markdown).toContain("Synthetic Slack-class roundtrip");
expect(markdown).toContain("- Status: pass");
});
});

View File

@@ -0,0 +1,289 @@
import fs from "node:fs";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { handleQaBusRequest, writeError, writeJson } from "./bus-server.js";
import { createQaBusState, type QaBusState } from "./bus-state.js";
import { createQaRunnerRuntime } from "./harness-runtime.js";
import { qaChannelPlugin, setQaChannelRuntime, type OpenClawConfig } from "./runtime-api.js";
import { runQaSelfCheckAgainstState, type QaSelfCheckResult } from "./self-check.js";
type QaLabLatestReport = {
outputPath: string;
markdown: string;
generatedAt: string;
};
async function readJson(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const text = Buffer.concat(chunks).toString("utf8").trim();
return text ? (JSON.parse(text) as unknown) : {};
}
function detectContentType(filePath: string): string {
if (filePath.endsWith(".css")) {
return "text/css; charset=utf-8";
}
if (filePath.endsWith(".js")) {
return "text/javascript; charset=utf-8";
}
if (filePath.endsWith(".json")) {
return "application/json; charset=utf-8";
}
if (filePath.endsWith(".svg")) {
return "image/svg+xml";
}
return "text/html; charset=utf-8";
}
function missingUiHtml() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>QA Lab UI Missing</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; background: #0f1115; color: #f5f7fb; margin: 0; display: grid; place-items: center; min-height: 100vh; }
main { max-width: 42rem; padding: 2rem; background: #171b22; border: 1px solid #283140; border-radius: 18px; box-shadow: 0 30px 80px rgba(0,0,0,.35); }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #9ee8d8; }
h1 { margin-top: 0; }
</style>
</head>
<body>
<main>
<h1>QA Lab UI not built</h1>
<p>Build the private debugger bundle, then reload this page.</p>
<p><code>pnpm qa:lab:build</code></p>
</main>
</body>
</html>`;
}
function resolveUiDistDir() {
return fileURLToPath(new URL("../web/dist", import.meta.url));
}
function tryResolveUiAsset(pathname: string): string | null {
const distDir = resolveUiDistDir();
if (!fs.existsSync(distDir)) {
return null;
}
const safePath = pathname === "/" ? "/index.html" : pathname;
const decoded = decodeURIComponent(safePath);
const candidate = path.normalize(path.join(distDir, decoded));
if (!candidate.startsWith(distDir)) {
return null;
}
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return candidate;
}
const fallback = path.join(distDir, "index.html");
return fs.existsSync(fallback) ? fallback : null;
}
function createQaLabConfig(baseUrl: string): OpenClawConfig {
return {
channels: {
"qa-channel": {
enabled: true,
baseUrl,
botUserId: "openclaw",
botDisplayName: "OpenClaw QA",
allowFrom: ["*"],
},
},
};
}
async function startQaGatewayLoop(params: { state: QaBusState; baseUrl: string }) {
const runtime = createQaRunnerRuntime();
setQaChannelRuntime(runtime);
const cfg = createQaLabConfig(params.baseUrl);
const account = qaChannelPlugin.config.resolveAccount(cfg, "default");
const abort = new AbortController();
const task = qaChannelPlugin.gateway?.startAccount?.({
accountId: account.accountId,
account,
cfg,
runtime: {
log: () => undefined,
error: () => undefined,
exit: () => undefined,
},
abortSignal: abort.signal,
log: {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
debug: () => undefined,
},
getStatus: () => ({
accountId: account.accountId,
configured: true,
enabled: true,
running: true,
}),
setStatus: () => undefined,
});
return {
cfg,
async stop() {
abort.abort();
await task;
},
};
}
export async function startQaLabServer(params?: {
host?: string;
port?: number;
outputPath?: string;
}) {
const state = createQaBusState();
let latestReport: QaLabLatestReport | null = null;
let gateway:
| {
cfg: OpenClawConfig;
stop: () => Promise<void>;
}
| undefined;
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (await handleQaBusRequest({ req, res, state })) {
return;
}
try {
if (req.method === "GET" && url.pathname === "/api/bootstrap") {
writeJson(res, 200, {
baseUrl,
latestReport,
defaults: {
conversationKind: "direct",
conversationId: "alice",
senderId: "alice",
senderName: "Alice",
},
});
return;
}
if (req.method === "GET" && url.pathname === "/api/state") {
writeJson(res, 200, state.getSnapshot());
return;
}
if (req.method === "GET" && url.pathname === "/api/report") {
writeJson(res, 200, { report: latestReport });
return;
}
if (req.method === "POST" && url.pathname === "/api/reset") {
state.reset();
writeJson(res, 200, { ok: true });
return;
}
if (req.method === "POST" && url.pathname === "/api/inbound/message") {
const body = await readJson(req);
writeJson(res, 200, {
message: state.addInboundMessage(body as Parameters<QaBusState["addInboundMessage"]>[0]),
});
return;
}
if (req.method === "POST" && url.pathname === "/api/scenario/self-check") {
const result = await runQaSelfCheckAgainstState({
state,
cfg: gateway?.cfg ?? createQaLabConfig(baseUrl),
outputPath: params?.outputPath,
});
latestReport = {
outputPath: result.outputPath,
markdown: result.report,
generatedAt: new Date().toISOString(),
};
writeJson(res, 200, serializeSelfCheck(result));
return;
}
if (req.method !== "GET" && req.method !== "HEAD") {
writeError(res, 404, "not found");
return;
}
const asset = tryResolveUiAsset(url.pathname);
if (!asset) {
const html = missingUiHtml();
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"content-length": Buffer.byteLength(html),
});
if (req.method === "HEAD") {
res.end();
return;
}
res.end(html);
return;
}
const body = fs.readFileSync(asset);
res.writeHead(200, {
"content-type": detectContentType(asset),
"content-length": body.byteLength,
});
if (req.method === "HEAD") {
res.end();
return;
}
res.end(body);
} catch (error) {
writeError(res, 500, error);
}
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(params?.port ?? 0, params?.host ?? "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("qa-lab failed to bind");
}
const baseUrl = `http://${params?.host ?? "127.0.0.1"}:${address.port}`;
gateway = await startQaGatewayLoop({ state, baseUrl });
return {
baseUrl,
state,
async runSelfCheck() {
const result = await runQaSelfCheckAgainstState({
state,
cfg: gateway!.cfg,
outputPath: params?.outputPath,
});
latestReport = {
outputPath: result.outputPath,
markdown: result.report,
generatedAt: new Date().toISOString(),
};
return result;
},
async stop() {
await gateway?.stop();
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
},
};
}
function serializeSelfCheck(result: QaSelfCheckResult) {
return {
outputPath: result.outputPath,
report: result.report,
checks: result.checks,
scenario: result.scenarioResult,
};
}

View File

@@ -0,0 +1,91 @@
export type QaReportCheck = {
name: string;
status: "pass" | "fail" | "skip";
details?: string;
};
export type QaReportScenario = {
name: string;
status: "pass" | "fail" | "skip";
details?: string;
steps?: QaReportCheck[];
};
export function renderQaMarkdownReport(params: {
title: string;
startedAt: Date;
finishedAt: Date;
checks?: QaReportCheck[];
scenarios?: QaReportScenario[];
timeline?: string[];
notes?: string[];
}) {
const checks = params.checks ?? [];
const scenarios = params.scenarios ?? [];
const passCount =
checks.filter((check) => check.status === "pass").length +
scenarios.filter((scenario) => scenario.status === "pass").length;
const failCount =
checks.filter((check) => check.status === "fail").length +
scenarios.filter((scenario) => scenario.status === "fail").length;
const lines = [
`# ${params.title}`,
"",
`- Started: ${params.startedAt.toISOString()}`,
`- Finished: ${params.finishedAt.toISOString()}`,
`- Duration ms: ${params.finishedAt.getTime() - params.startedAt.getTime()}`,
`- Passed: ${passCount}`,
`- Failed: ${failCount}`,
"",
];
if (checks.length > 0) {
lines.push("## Checks", "");
for (const check of checks) {
lines.push(`- [${check.status === "pass" ? "x" : " "}] ${check.name}`);
if (check.details) {
lines.push(` - ${check.details}`);
}
}
}
if (scenarios.length > 0) {
lines.push("", "## Scenarios", "");
for (const scenario of scenarios) {
lines.push(`### ${scenario.name}`);
lines.push("");
lines.push(`- Status: ${scenario.status}`);
if (scenario.details) {
lines.push(`- Details: ${scenario.details}`);
}
if (scenario.steps?.length) {
lines.push("- Steps:");
for (const step of scenario.steps) {
lines.push(` - [${step.status === "pass" ? "x" : " "}] ${step.name}`);
if (step.details) {
lines.push(` - ${step.details}`);
}
}
}
lines.push("");
}
}
if (params.timeline && params.timeline.length > 0) {
lines.push("## Timeline", "");
for (const item of params.timeline) {
lines.push(`- ${item}`);
}
}
if (params.notes && params.notes.length > 0) {
lines.push("", "## Notes", "");
for (const note of params.notes) {
lines.push(`- ${note}`);
}
}
lines.push("");
return lines.join("\n");
}

View File

@@ -0,0 +1,38 @@
export type { Command } from "commander";
export type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
export { definePluginEntry } from "openclaw/plugin-sdk/core";
export {
buildQaTarget,
createQaBusThread,
deleteQaBusMessage,
editQaBusMessage,
getQaBusState,
injectQaBusInboundMessage,
normalizeQaTarget,
parseQaTarget,
pollQaBus,
qaChannelPlugin,
reactToQaBusMessage,
readQaBusMessage,
searchQaBusMessages,
sendQaBusMessage,
setQaChannelRuntime,
} from "../../qa-channel/api.js";
export type {
QaBusConversation,
QaBusCreateThreadInput,
QaBusDeleteMessageInput,
QaBusEditMessageInput,
QaBusEvent,
QaBusInboundMessageInput,
QaBusMessage,
QaBusOutboundMessageInput,
QaBusPollInput,
QaBusPollResult,
QaBusReactToMessageInput,
QaBusReadMessageInput,
QaBusSearchMessagesInput,
QaBusStateSnapshot,
QaBusThread,
QaBusWaitForInput,
} from "../../qa-channel/api.js";

View File

@@ -0,0 +1,65 @@
import type { QaBusState } from "./bus-state.js";
export type QaScenarioStepContext = {
state: QaBusState;
};
export type QaScenarioStep = {
name: string;
run: (ctx: QaScenarioStepContext) => Promise<string | void>;
};
export type QaScenarioDefinition = {
name: string;
steps: QaScenarioStep[];
};
export type QaScenarioStepResult = {
name: string;
status: "pass" | "fail";
details?: string;
};
export type QaScenarioResult = {
name: string;
status: "pass" | "fail";
steps: QaScenarioStepResult[];
details?: string;
};
export async function runQaScenario(
definition: QaScenarioDefinition,
ctx: QaScenarioStepContext,
): Promise<QaScenarioResult> {
const steps: QaScenarioStepResult[] = [];
for (const step of definition.steps) {
try {
const details = await step.run(ctx);
steps.push({
name: step.name,
status: "pass",
...(details ? { details } : {}),
});
} catch (error) {
const details = error instanceof Error ? error.message : String(error);
steps.push({
name: step.name,
status: "fail",
details,
});
return {
name: definition.name,
status: "fail",
steps,
details,
};
}
}
return {
name: definition.name,
status: "pass",
steps,
};
}

View File

@@ -0,0 +1,121 @@
import { extractQaToolPayload } from "./extract-tool-payload.js";
import { qaChannelPlugin, type OpenClawConfig } from "./runtime-api.js";
import type { QaScenarioDefinition } from "./scenario.js";
export function createQaSelfCheckScenario(cfg: OpenClawConfig): QaScenarioDefinition {
return {
name: "Synthetic Slack-class roundtrip",
steps: [
{
name: "DM echo roundtrip",
async run({ state }) {
state.addInboundMessage({
conversation: { id: "alice", kind: "direct" },
senderId: "alice",
senderName: "Alice",
text: "hello from qa",
});
await state.waitFor({
kind: "message-text",
textIncludes: "qa-echo: hello from qa",
direction: "outbound",
timeoutMs: 5_000,
});
},
},
{
name: "Thread create and threaded echo",
async run({ state }) {
const threadResult = await qaChannelPlugin.actions?.handleAction?.({
channel: "qa-channel",
action: "thread-create",
cfg,
accountId: "default",
params: {
channelId: "qa-room",
title: "QA thread",
},
});
const threadPayload = extractQaToolPayload(threadResult) as
| { thread?: { id?: string } }
| undefined;
const threadId = threadPayload?.thread?.id;
if (!threadId) {
throw new Error("thread-create did not return thread id");
}
state.addInboundMessage({
conversation: { id: "qa-room", kind: "channel", title: "QA Room" },
senderId: "alice",
senderName: "Alice",
text: "inside thread",
threadId,
threadTitle: "QA thread",
});
await state.waitFor({
kind: "message-text",
textIncludes: "qa-echo: inside thread",
direction: "outbound",
timeoutMs: 5_000,
});
return threadId;
},
},
{
name: "Reaction, edit, delete lifecycle",
async run({ state }) {
const outbound = state
.searchMessages({ query: "qa-echo: inside thread", conversationId: "qa-room" })
.at(-1);
if (!outbound) {
throw new Error("threaded outbound message not found");
}
await qaChannelPlugin.actions?.handleAction?.({
channel: "qa-channel",
action: "react",
cfg,
accountId: "default",
params: {
messageId: outbound.id,
emoji: "white_check_mark",
},
});
const reacted = state.readMessage({ messageId: outbound.id });
if (reacted.reactions.length === 0) {
throw new Error("reaction not recorded");
}
await qaChannelPlugin.actions?.handleAction?.({
channel: "qa-channel",
action: "edit",
cfg,
accountId: "default",
params: {
messageId: outbound.id,
text: "qa-echo: inside thread (edited)",
},
});
const edited = state.readMessage({ messageId: outbound.id });
if (!edited.text.includes("(edited)")) {
throw new Error("edit not recorded");
}
await qaChannelPlugin.actions?.handleAction?.({
channel: "qa-channel",
action: "delete",
cfg,
accountId: "default",
params: {
messageId: outbound.id,
},
});
const deleted = state.readMessage({ messageId: outbound.id });
if (!deleted.deleted) {
throw new Error("delete not recorded");
}
},
},
],
};
}

View File

@@ -0,0 +1,91 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import type { QaBusState } from "./bus-state.js";
import { startQaLabServer } from "./lab-server.js";
import { renderQaMarkdownReport } from "./report.js";
import { runQaScenario, type QaScenarioResult } from "./scenario.js";
import { createQaSelfCheckScenario } from "./self-check-scenario.js";
export type QaSelfCheckResult = {
outputPath: string;
report: string;
checks: Array<{ name: string; status: "pass" | "fail"; details?: string }>;
scenarioResult: QaScenarioResult;
};
export async function runQaSelfCheckAgainstState(params: {
state: QaBusState;
cfg: OpenClawConfig;
outputPath?: string;
notes?: string[];
}): Promise<QaSelfCheckResult> {
const startedAt = new Date();
params.state.reset();
const scenarioResult = await runQaScenario(createQaSelfCheckScenario(params.cfg), {
state: params.state,
});
const checks = [
{
name: "QA self-check scenario",
status: scenarioResult.status,
details: `${scenarioResult.steps.filter((step) => step.status === "pass").length}/${scenarioResult.steps.length} steps passed`,
},
] satisfies Array<{ name: string; status: "pass" | "fail"; details?: string }>;
const finishedAt = new Date();
const snapshot = params.state.getSnapshot();
const timeline = snapshot.events.map((event) => {
switch (event.kind) {
case "thread-created":
return `${event.cursor}. ${event.kind} ${event.thread.conversationId}/${event.thread.id}`;
case "reaction-added":
return `${event.cursor}. ${event.kind} ${event.message.id} ${event.emoji}`;
default:
return `${event.cursor}. ${event.kind} ${"message" in event ? event.message.id : ""}`.trim();
}
});
const report = renderQaMarkdownReport({
title: "OpenClaw QA E2E Self-Check",
startedAt,
finishedAt,
checks,
scenarios: [
{
name: scenarioResult.name,
status: scenarioResult.status,
details: scenarioResult.details,
steps: scenarioResult.steps,
},
],
timeline,
notes: params.notes ?? [
"Vertical slice: qa-channel + qa-lab bus + private debugger surface.",
"Docker orchestration, matrix runs, and auto-fix loops remain follow-up work.",
],
});
const outputPath =
params.outputPath ?? path.join(process.cwd(), ".artifacts", "qa-e2e", "self-check.md");
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, report, "utf8");
return {
outputPath,
report,
checks,
scenarioResult,
};
}
export async function runQaLabSelfCheck(params?: { outputPath?: string }) {
const server = await startQaLabServer({
outputPath: params?.outputPath,
});
try {
return await server.runSelfCheck();
} finally {
await server.stop();
}
}
export const runQaE2eSelfCheck = runQaLabSelfCheck;

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>QA Lab</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,498 @@
type Conversation = {
id: string;
kind: "direct" | "channel";
title?: string;
};
type Thread = {
id: string;
conversationId: string;
title: string;
};
type Message = {
id: string;
direction: "inbound" | "outbound";
conversation: Conversation;
senderId: string;
senderName?: string;
text: string;
timestamp: number;
threadId?: string;
threadTitle?: string;
deleted?: boolean;
editedAt?: number;
reactions: Array<{ emoji: string; senderId: string }>;
};
type BusEvent =
| { cursor: number; kind: "thread-created"; thread: Thread }
| { cursor: number; kind: string; message?: Message; emoji?: string };
type Snapshot = {
conversations: Conversation[];
threads: Thread[];
messages: Message[];
events: BusEvent[];
};
type ReportEnvelope = {
report: null | {
outputPath: string;
markdown: string;
generatedAt: string;
};
};
type Bootstrap = {
baseUrl: string;
latestReport: ReportEnvelope["report"];
defaults: {
conversationKind: "direct" | "channel";
conversationId: string;
senderId: string;
senderName: string;
};
};
type UiState = {
bootstrap: Bootstrap | null;
snapshot: Snapshot | null;
latestReport: ReportEnvelope["report"];
selectedConversationId: string | null;
selectedThreadId: string | null;
composer: {
conversationKind: "direct" | "channel";
conversationId: string;
senderId: string;
senderName: string;
text: string;
};
busy: boolean;
error: string | null;
};
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return (await response.json()) as T;
}
async function postJson<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(path, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as { error?: string };
throw new Error(payload.error || `${response.status} ${response.statusText}`);
}
return (await response.json()) as T;
}
function formatTime(timestamp: number) {
return new Date(timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function escapeHtml(text: string) {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function filteredMessages(state: UiState) {
const messages = state.snapshot?.messages ?? [];
return messages.filter((message) => {
if (state.selectedConversationId && message.conversation.id !== state.selectedConversationId) {
return false;
}
if (state.selectedThreadId && message.threadId !== state.selectedThreadId) {
return false;
}
return true;
});
}
function deriveSelectedConversation(state: UiState): string | null {
if (state.selectedConversationId) {
return state.selectedConversationId;
}
return state.snapshot?.conversations[0]?.id ?? null;
}
function deriveSelectedThread(state: UiState): string | null {
if (state.selectedThreadId) {
return state.selectedThreadId;
}
return null;
}
export async function createQaLabApp(root: HTMLDivElement) {
const state: UiState = {
bootstrap: null,
snapshot: null,
latestReport: null,
selectedConversationId: null,
selectedThreadId: null,
composer: {
conversationKind: "direct",
conversationId: "alice",
senderId: "alice",
senderName: "Alice",
text: "",
},
busy: false,
error: null,
};
async function refresh() {
try {
const [bootstrap, snapshot, report] = await Promise.all([
getJson<Bootstrap>("/api/bootstrap"),
getJson<Snapshot>("/api/state"),
getJson<ReportEnvelope>("/api/report"),
]);
state.bootstrap = bootstrap;
state.snapshot = snapshot;
state.latestReport = report.report ?? bootstrap.latestReport;
if (!state.selectedConversationId) {
state.selectedConversationId = snapshot.conversations[0]?.id ?? null;
}
if (!state.composer.conversationId) {
state.composer = {
...state.composer,
conversationKind: bootstrap.defaults.conversationKind,
conversationId: bootstrap.defaults.conversationId,
senderId: bootstrap.defaults.senderId,
senderName: bootstrap.defaults.senderName,
};
}
state.error = null;
} catch (error) {
state.error = error instanceof Error ? error.message : String(error);
}
render();
}
async function runSelfCheck() {
state.busy = true;
state.error = null;
render();
try {
const result = await postJson<{ report: string; outputPath: string }>(
"/api/scenario/self-check",
{},
);
state.latestReport = {
outputPath: result.outputPath,
markdown: result.report,
generatedAt: new Date().toISOString(),
};
await refresh();
} catch (error) {
state.error = error instanceof Error ? error.message : String(error);
render();
} finally {
state.busy = false;
render();
}
}
async function resetState() {
state.busy = true;
render();
try {
await postJson("/api/reset", {});
state.latestReport = null;
state.selectedThreadId = null;
await refresh();
} catch (error) {
state.error = error instanceof Error ? error.message : String(error);
render();
} finally {
state.busy = false;
render();
}
}
async function sendInbound() {
const conversationId = state.composer.conversationId.trim();
const text = state.composer.text.trim();
if (!conversationId || !text) {
state.error = "Conversation id and text are required.";
render();
return;
}
state.busy = true;
state.error = null;
render();
try {
await postJson("/api/inbound/message", {
conversation: {
id: conversationId,
kind: state.composer.conversationKind,
...(state.composer.conversationKind === "channel" ? { title: conversationId } : {}),
},
senderId: state.composer.senderId.trim() || "alice",
senderName: state.composer.senderName.trim() || undefined,
text,
...(state.selectedThreadId ? { threadId: state.selectedThreadId } : {}),
});
state.selectedConversationId = conversationId;
state.composer.text = "";
await refresh();
} catch (error) {
state.error = error instanceof Error ? error.message : String(error);
render();
} finally {
state.busy = false;
render();
}
}
function downloadReport() {
if (!state.latestReport?.markdown) {
return;
}
const blob = new Blob([state.latestReport.markdown], { type: "text/markdown;charset=utf-8" });
const href = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = href;
anchor.download = "qa-report.md";
anchor.click();
URL.revokeObjectURL(href);
}
function bindEvents() {
root.querySelectorAll<HTMLElement>("[data-conversation-id]").forEach((node) => {
node.onclick = () => {
state.selectedConversationId = node.dataset.conversationId ?? null;
state.selectedThreadId = null;
render();
};
});
root.querySelectorAll<HTMLElement>("[data-thread-id]").forEach((node) => {
node.onclick = () => {
state.selectedConversationId = node.dataset.conversationId ?? null;
state.selectedThreadId = node.dataset.threadId ?? null;
render();
};
});
root.querySelector<HTMLButtonElement>("[data-action='refresh']")!.onclick = () => {
void refresh();
};
root.querySelector<HTMLButtonElement>("[data-action='reset']")!.onclick = () => {
void resetState();
};
root.querySelector<HTMLButtonElement>("[data-action='self-check']")!.onclick = () => {
void runSelfCheck();
};
root.querySelector<HTMLButtonElement>("[data-action='send']")!.onclick = () => {
void sendInbound();
};
root.querySelector<HTMLButtonElement>("[data-action='download-report']")!.onclick = () => {
downloadReport();
};
root.querySelector<HTMLSelectElement>("#conversation-kind")!.onchange = (event) => {
const target = event.currentTarget as HTMLSelectElement;
state.composer.conversationKind = target.value === "channel" ? "channel" : "direct";
};
root.querySelector<HTMLInputElement>("#conversation-id")!.oninput = (event) => {
state.composer.conversationId = (event.currentTarget as HTMLInputElement).value;
};
root.querySelector<HTMLInputElement>("#sender-id")!.oninput = (event) => {
state.composer.senderId = (event.currentTarget as HTMLInputElement).value;
};
root.querySelector<HTMLInputElement>("#sender-name")!.oninput = (event) => {
state.composer.senderName = (event.currentTarget as HTMLInputElement).value;
};
root.querySelector<HTMLTextAreaElement>("#composer-text")!.oninput = (event) => {
state.composer.text = (event.currentTarget as HTMLTextAreaElement).value;
};
}
function render() {
const selectedConversationId = deriveSelectedConversation(state);
const selectedThreadId = deriveSelectedThread(state);
const conversations = state.snapshot?.conversations ?? [];
const threads = (state.snapshot?.threads ?? []).filter(
(thread) => !selectedConversationId || thread.conversationId === selectedConversationId,
);
const messages = filteredMessages({
...state,
selectedConversationId,
selectedThreadId,
});
const events = (state.snapshot?.events ?? []).slice(-20).reverse();
root.innerHTML = `
<div class="shell">
<header class="topbar">
<div>
<p class="eyebrow">Private QA Workspace</p>
<h1>QA Lab</h1>
<p class="subtle">Synthetic Slack-style debugger for qa-channel.</p>
</div>
<div class="toolbar">
<button data-action="refresh"${state.busy ? " disabled" : ""}>Refresh</button>
<button data-action="reset"${state.busy ? " disabled" : ""}>Reset</button>
<button class="accent" data-action="self-check"${state.busy ? " disabled" : ""}>Run Self-Check</button>
</div>
</header>
<section class="statusbar">
<span class="pill">Bus ${state.bootstrap ? "online" : "booting"}</span>
<span class="pill">Conversation ${selectedConversationId ?? "none"}</span>
<span class="pill">Thread ${selectedThreadId ?? "root"}</span>
${state.latestReport ? `<span class="pill success">Report ${escapeHtml(state.latestReport.outputPath)}</span>` : '<span class="pill">No report yet</span>'}
${state.error ? `<span class="pill error">${escapeHtml(state.error)}</span>` : ""}
</section>
<main class="workspace">
<aside class="rail">
<section class="panel">
<h2>Conversations</h2>
<div class="stack">
${conversations
.map(
(conversation) => `
<button class="list-item${conversation.id === selectedConversationId ? " selected" : ""}" data-conversation-id="${escapeHtml(conversation.id)}">
<strong>${escapeHtml(conversation.title || conversation.id)}</strong>
<span>${conversation.kind}</span>
</button>`,
)
.join("")}
</div>
</section>
<section class="panel">
<h2>Threads</h2>
<div class="stack">
<button class="list-item${!selectedThreadId ? " selected" : ""}" data-conversation-id="${escapeHtml(selectedConversationId ?? "")}">
<strong>Main timeline</strong>
<span>root</span>
</button>
${threads
.map(
(thread) => `
<button class="list-item${thread.id === selectedThreadId ? " selected" : ""}" data-thread-id="${escapeHtml(thread.id)}" data-conversation-id="${escapeHtml(thread.conversationId)}">
<strong>${escapeHtml(thread.title)}</strong>
<span>${escapeHtml(thread.id)}</span>
</button>`,
)
.join("")}
</div>
</section>
</aside>
<section class="center">
<section class="panel transcript">
<h2>Transcript</h2>
<div class="messages">
${
messages.length === 0
? '<p class="empty">No messages in this slice yet.</p>'
: messages
.map(
(message) => `
<article class="message ${message.direction}">
<header>
<strong>${escapeHtml(message.senderName || message.senderId)}</strong>
<span>${message.direction}</span>
<time>${formatTime(message.timestamp)}</time>
</header>
<p>${escapeHtml(message.text)}</p>
<footer>
<span>${escapeHtml(message.id)}</span>
${message.threadId ? `<span>thread ${escapeHtml(message.threadId)}</span>` : ""}
${message.editedAt ? "<span>edited</span>" : ""}
${message.deleted ? "<span>deleted</span>" : ""}
${message.reactions.length ? `<span>${message.reactions.map((reaction) => reaction.emoji).join(" ")}</span>` : ""}
</footer>
</article>`,
)
.join("")
}
</div>
</section>
<section class="panel composer">
<h2>Inject inbound</h2>
<div class="composer-grid">
<label>
<span>Kind</span>
<select id="conversation-kind">
<option value="direct"${state.composer.conversationKind === "direct" ? " selected" : ""}>Direct</option>
<option value="channel"${state.composer.conversationKind === "channel" ? " selected" : ""}>Channel</option>
</select>
</label>
<label>
<span>Conversation</span>
<input id="conversation-id" value="${escapeHtml(state.composer.conversationId)}" />
</label>
<label>
<span>Sender id</span>
<input id="sender-id" value="${escapeHtml(state.composer.senderId)}" />
</label>
<label>
<span>Sender name</span>
<input id="sender-name" value="${escapeHtml(state.composer.senderName)}" />
</label>
</div>
<label class="textarea-label">
<span>Message</span>
<textarea id="composer-text" rows="4" placeholder="Ask the agent to do something interesting...">${escapeHtml(state.composer.text)}</textarea>
</label>
<div class="toolbar lower">
<button class="accent" data-action="send"${state.busy ? " disabled" : ""}>Send inbound</button>
</div>
</section>
</section>
<aside class="rail right">
<section class="panel">
<div class="panel-header">
<h2>Latest report</h2>
<button data-action="download-report"${state.latestReport ? "" : " disabled"}>Export</button>
</div>
<pre class="report">${escapeHtml(state.latestReport?.markdown ?? "Run the self-check to generate a Markdown protocol report.")}</pre>
</section>
<section class="panel events">
<h2>Event stream</h2>
<div class="stack">
${events
.map((event) => {
const tail =
"thread" in event
? `${event.thread.conversationId}/${event.thread.id}`
: event.message
? `${event.message.senderId}: ${event.message.text}`
: "";
return `
<div class="event-row">
<strong>${escapeHtml(event.kind)}</strong>
<span>#${event.cursor}</span>
<code>${escapeHtml(tail)}</code>
</div>`;
})
.join("")}
</div>
</section>
</aside>
</main>
</div>`;
bindEvents();
}
render();
await refresh();
setInterval(() => {
void refresh();
}, 1_000);
}

View File

@@ -0,0 +1,10 @@
import "./styles.css";
import { createQaLabApp } from "./app";
const root = document.querySelector<HTMLDivElement>("#app");
if (!root) {
throw new Error("QA Lab app root missing");
}
void createQaLabApp(root);

View File

@@ -0,0 +1,305 @@
:root {
color-scheme: dark;
--bg: #0b0f14;
--bg-alt: #11161d;
--panel: rgba(17, 22, 29, 0.92);
--panel-strong: rgba(22, 29, 38, 0.98);
--line: rgba(145, 170, 197, 0.16);
--text: #eff4fb;
--muted: #8c98a8;
--accent: #65d6bf;
--accent-2: #ff9b57;
--danger: #ff7b88;
--shadow: 0 30px 80px rgba(0, 0, 0, 0.35);
--radius: 20px;
font-family:
ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(101, 214, 191, 0.14), transparent 30%),
radial-gradient(circle at top right, rgba(255, 155, 87, 0.12), transparent 28%),
linear-gradient(180deg, #0a0f14, #0c1218 58%, #0a0f14);
color: var(--text);
}
button,
input,
select,
textarea {
font: inherit;
}
button,
input,
select,
textarea {
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.02);
color: var(--text);
}
button {
cursor: pointer;
padding: 0.7rem 1rem;
}
button.accent {
background: linear-gradient(135deg, var(--accent), #7bdad0);
border-color: transparent;
color: #04130f;
font-weight: 700;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
input,
select,
textarea {
width: 100%;
padding: 0.75rem 0.85rem;
}
textarea {
resize: vertical;
}
.shell {
padding: 1.2rem;
}
.topbar,
.statusbar,
.workspace {
max-width: 1600px;
margin: 0 auto;
}
.topbar {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: end;
margin-bottom: 0.8rem;
}
.topbar h1,
.panel h2 {
margin: 0;
}
.eyebrow {
margin: 0 0 0.25rem;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.72rem;
}
.subtle {
margin: 0.25rem 0 0;
color: var(--muted);
}
.toolbar {
display: flex;
gap: 0.7rem;
flex-wrap: wrap;
}
.statusbar {
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.pill {
padding: 0.45rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--line);
color: var(--muted);
}
.pill.success {
color: var(--accent);
}
.pill.error {
color: var(--danger);
}
.workspace {
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 360px;
gap: 1rem;
}
.rail,
.center,
.right {
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 0;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1rem;
min-height: 0;
}
.panel-header {
display: flex;
justify-content: space-between;
gap: 0.7rem;
align-items: center;
margin-bottom: 0.7rem;
}
.stack {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.list-item {
display: flex;
flex-direction: column;
align-items: start;
gap: 0.15rem;
text-align: left;
}
.list-item.selected {
background: rgba(101, 214, 191, 0.12);
border-color: rgba(101, 214, 191, 0.4);
}
.list-item span,
.message footer,
.event-row span {
color: var(--muted);
font-size: 0.82rem;
}
.transcript,
.events {
flex: 1;
}
.messages,
.report {
min-height: 0;
max-height: 46vh;
overflow: auto;
}
.message {
padding: 0.9rem;
border-radius: 16px;
margin-bottom: 0.8rem;
border: 1px solid rgba(255, 255, 255, 0.04);
background: var(--panel-strong);
}
.message.inbound {
border-left: 3px solid var(--accent-2);
}
.message.outbound {
border-left: 3px solid var(--accent);
}
.message header,
.message footer,
.event-row {
display: flex;
gap: 0.55rem;
align-items: center;
flex-wrap: wrap;
}
.message p {
margin: 0.65rem 0;
white-space: pre-wrap;
}
.composer-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
label span {
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.84rem;
}
.textarea-label {
display: block;
margin-top: 0.85rem;
}
.lower {
margin-top: 0.85rem;
}
.report {
margin: 0;
padding: 0.8rem;
border-radius: 16px;
background: rgba(7, 10, 14, 0.7);
border: 1px solid rgba(255, 255, 255, 0.04);
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.78rem;
line-height: 1.45;
}
.event-row {
padding: 0.75rem;
border-radius: 14px;
background: rgba(255, 255, 255, 0.03);
}
.event-row code {
display: block;
width: 100%;
color: #c7d2e3;
white-space: pre-wrap;
word-break: break-word;
}
.empty {
color: var(--muted);
}
@media (max-width: 1180px) {
.workspace {
grid-template-columns: 1fr;
}
.messages,
.report {
max-height: none;
}
}

View File

@@ -0,0 +1,11 @@
import path from "node:path";
import { defineConfig } from "vite";
export default defineConfig({
root: path.resolve(import.meta.dirname),
base: "./",
build: {
outDir: path.resolve(import.meta.dirname, "dist"),
emptyOutDir: true,
},
});

View File

@@ -695,6 +695,10 @@
"types": "./dist/plugin-sdk/nostr.d.ts",
"default": "./dist/plugin-sdk/nostr.js"
},
"./plugin-sdk/qa-channel": {
"types": "./dist/plugin-sdk/qa-channel.d.ts",
"default": "./dist/plugin-sdk/qa-channel.js"
},
"./plugin-sdk/provider-auth": {
"types": "./dist/plugin-sdk/provider-auth.d.ts",
"default": "./dist/plugin-sdk/provider-auth.js"
@@ -995,7 +999,10 @@
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
"release:check": "pnpm check:base-config-schema && pnpm check:bundled-channel-config-metadata && pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:facades:check && pnpm plugin-sdk:api:check && node --import tsx scripts/release-check.ts",
"qa:e2e": "node --import tsx scripts/qa-e2e.ts",
"qa:lab:build": "vite build --config extensions/qa-lab/web/vite.config.ts",
"qa:lab:ui": "pnpm openclaw qa ui",
"release:check": "pnpm check:base-config-schema && pnpm check:bundled-channel-config-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:facades:check && pnpm plugin-sdk:api:check && node --import tsx scripts/release-check.ts",
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts",
"release:openclaw:npm:verify-published": "node --import tsx scripts/openclaw-npm-postpublish-verify.ts",
"release:plugins:clawhub:check": "node --import tsx scripts/plugin-clawhub-release-check.ts",

View File

@@ -163,6 +163,7 @@
"native-command-registry",
"nextcloud-talk",
"nostr",
"qa-channel",
"provider-auth",
"provider-auth-runtime",
"provider-auth-api-key",

6
scripts/qa-e2e.ts Normal file
View File

@@ -0,0 +1,6 @@
import { runQaE2eSelfCheck } from "../src/qa-e2e/runner.js";
const outputPath = process.argv[2]?.trim() || ".artifacts/qa-e2e/self-check.md";
const result = await runQaE2eSelfCheck({ outputPath });
process.stdout.write(`QA self-check report: ${result.outputPath}\n`);

View File

@@ -1,6 +1,7 @@
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
import type { PluginPackageChannel } from "../plugins/manifest.js";
import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "./ids.js";
import { resolveChannelExposure } from "./plugins/exposure.js";
import type { ChannelMeta } from "./plugins/types.js";
export type ChatChannelMeta = ChannelMeta;
@@ -15,6 +16,7 @@ function toChatChannelMeta(params: {
if (!label) {
throw new Error(`Missing label for bundled chat channel "${params.id}"`);
}
const exposure = resolveChannelExposure(params.channel);
return {
id: params.id,
@@ -43,9 +45,7 @@ function toChatChannelMeta(params: {
...(params.channel.markdownCapable !== undefined
? { markdownCapable: params.channel.markdownCapable }
: {}),
...(params.channel.showConfigured !== undefined
? { showConfigured: params.channel.showConfigured }
: {}),
exposure,
...(params.channel.quickstartAllowFrom !== undefined
? { quickstartAllowFrom: params.channel.quickstartAllowFrom }
: {}),

View File

@@ -9,6 +9,7 @@ import type { OpenClawPackageManifest } from "../../plugins/manifest.js";
import type { PackageManifest as PluginPackageManifest } from "../../plugins/manifest.js";
import type { PluginOrigin } from "../../plugins/types.js";
import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js";
import { resolveChannelExposure } from "./exposure.js";
import type { ChannelMeta } from "./types.js";
export type ChannelUiMetaEntry = {
@@ -182,6 +183,7 @@ function toChannelMeta(params: {
const docsPath = params.channel.docsPath?.trim() || `/channels/${params.id}`;
const blurb = params.channel.blurb?.trim() || "";
const systemImage = params.channel.systemImage?.trim();
const exposure = resolveChannelExposure(params.channel);
return {
id: params.id,
@@ -205,9 +207,7 @@ function toChannelMeta(params: {
...(params.channel.markdownCapable !== undefined
? { markdownCapable: params.channel.markdownCapable }
: {}),
...(params.channel.showConfigured !== undefined
? { showConfigured: params.channel.showConfigured }
: {}),
exposure,
...(params.channel.quickstartAllowFrom !== undefined
? { quickstartAllowFrom: params.channel.quickstartAllowFrom }
: {}),

View File

@@ -0,0 +1,29 @@
import type { ChannelMeta } from "./types.js";
export function resolveChannelExposure(
meta: Pick<ChannelMeta, "exposure" | "showConfigured" | "showInSetup">,
) {
return {
configured: meta.exposure?.configured ?? meta.showConfigured ?? true,
setup: meta.exposure?.setup ?? meta.showInSetup ?? true,
docs: meta.exposure?.docs ?? true,
};
}
export function isChannelVisibleInConfiguredLists(
meta: Pick<ChannelMeta, "exposure" | "showConfigured" | "showInSetup">,
): boolean {
return resolveChannelExposure(meta).configured;
}
export function isChannelVisibleInSetup(
meta: Pick<ChannelMeta, "exposure" | "showConfigured" | "showInSetup">,
): boolean {
return resolveChannelExposure(meta).setup;
}
export function isChannelVisibleInDocs(
meta: Pick<ChannelMeta, "exposure" | "showConfigured" | "showInSetup">,
): boolean {
return resolveChannelExposure(meta).docs;
}

View File

@@ -13,6 +13,12 @@ import type { ChannelMessageCapability } from "./message-capabilities.js";
export type ChannelId = ChatChannelId | (string & {});
export type ChannelExposure = {
configured?: boolean;
setup?: boolean;
docs?: boolean;
};
export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat";
/** Agent tool registered by a channel plugin. */
@@ -143,7 +149,9 @@ export type ChannelMeta = {
detailLabel?: string;
systemImage?: string;
markdownCapable?: boolean;
exposure?: ChannelExposure;
showConfigured?: boolean;
showInSetup?: boolean;
quickstartAllowFrom?: boolean;
forceAccountBinding?: boolean;
preferSessionLookupForAnnounceTarget?: boolean;

View File

@@ -1,3 +1,4 @@
import { isChannelVisibleInConfiguredLists } from "../channels/plugins/exposure.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import {
getChannelPlugin,
@@ -104,7 +105,7 @@ function shouldShowProviderEntry(entry: ProviderAccountStatus, cfg: OpenClawConf
if (!plugin) {
return Boolean(entry.configured);
}
if (plugin.meta.showConfigured === false) {
if (!isChannelVisibleInConfiguredLists(plugin.meta)) {
const providerConfig = (cfg as Record<string, unknown>)[plugin.id];
return Boolean(entry.configured) || Boolean(providerConfig);
}

View File

@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginAutoEnableResult } from "../../config/plugin-auto-enable.js";
const loadPluginManifestRegistry = vi.hoisted(() => vi.fn());
const listChannelPluginCatalogEntries = vi.hoisted(() => vi.fn((): unknown[] => []));
const listChatChannels = vi.hoisted(() => vi.fn((): Array<Record<string, string>> => []));
const applyPluginAutoEnable = vi.hoisted(() =>
vi.fn<(args: { config: unknown; env?: NodeJS.ProcessEnv }) => PluginAutoEnableResult>(
({ config }) => ({
@@ -21,11 +23,24 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable(args as { config: unknown; env?: NodeJS.ProcessEnv }),
}));
import { listManifestInstalledChannelIds } from "./discovery.js";
vi.mock("../../channels/plugins/catalog.js", () => ({
listChannelPluginCatalogEntries: (_args?: unknown) => listChannelPluginCatalogEntries(),
}));
vi.mock("../../channels/registry.js", () => ({
listChatChannels: () => listChatChannels(),
}));
import { listManifestInstalledChannelIds, resolveChannelSetupEntries } from "./discovery.js";
describe("listManifestInstalledChannelIds", () => {
beforeEach(() => {
loadPluginManifestRegistry.mockReset();
loadPluginManifestRegistry.mockReset().mockReturnValue({
plugins: [],
diagnostics: [],
});
listChannelPluginCatalogEntries.mockReset().mockReturnValue([]);
listChatChannels.mockReset().mockReturnValue([]);
applyPluginAutoEnable.mockReset().mockImplementation(({ config }) => ({
config: config as never,
changes: [] as string[],
@@ -68,4 +83,37 @@ describe("listManifestInstalledChannelIds", () => {
});
expect(installedIds).toEqual(new Set(["slack"]));
});
it("filters channels hidden from setup out of interactive entries", () => {
listChatChannels.mockReturnValue([
{
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "bot token",
},
]);
const resolved = resolveChannelSetupEntries({
cfg: {} as never,
installedPlugins: [
{
id: "qa-channel",
meta: {
id: "qa-channel",
label: "QA Channel",
selectionLabel: "QA Channel",
docsPath: "/channels/qa-channel",
blurb: "synthetic",
exposure: { setup: false },
},
} as never,
],
workspaceDir: "/tmp/workspace",
env: { OPENCLAW_HOME: "/tmp/home" } as NodeJS.ProcessEnv,
});
expect(resolved.entries.map((entry) => entry.id)).toEqual(["telegram"]);
});
});

View File

@@ -3,6 +3,7 @@ import {
listChannelPluginCatalogEntries,
type ChannelPluginCatalogEntry,
} from "../../channels/plugins/catalog.js";
import { isChannelVisibleInSetup } from "../../channels/plugins/exposure.js";
import type { ChannelMeta, ChannelPlugin } from "../../channels/plugins/types.js";
import { listChatChannels } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -15,6 +16,12 @@ type ChannelCatalogEntry = {
meta: ChannelMeta;
};
export function shouldShowChannelInSetup(
meta: Pick<ChannelMeta, "exposure" | "showConfigured" | "showInSetup">,
): boolean {
return isChannelVisibleInSetup(meta);
}
export type ResolvedChannelSetupEntries = {
entries: ChannelCatalogEntry[];
installedCatalogEntries: ChannelPluginCatalogEntry[];
@@ -71,11 +78,15 @@ export function resolveChannelSetupEntries(params: {
const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir });
const installedCatalogEntries = catalogEntries.filter(
(entry) =>
!installedPluginIds.has(entry.id) && manifestInstalledIds.has(entry.id as ChannelChoice),
!installedPluginIds.has(entry.id) &&
manifestInstalledIds.has(entry.id as ChannelChoice) &&
shouldShowChannelInSetup(entry.meta),
);
const installableCatalogEntries = catalogEntries.filter(
(entry) =>
!installedPluginIds.has(entry.id) && !manifestInstalledIds.has(entry.id as ChannelChoice),
!installedPluginIds.has(entry.id) &&
!manifestInstalledIds.has(entry.id as ChannelChoice) &&
shouldShowChannelInSetup(entry.meta),
);
const metaById = new Map<string, ChannelMeta>();
@@ -100,7 +111,7 @@ export function resolveChannelSetupEntries(params: {
entries: Array.from(metaById, ([id, meta]) => ({
id: id as ChannelChoice,
meta,
})),
})).filter((entry) => shouldShowChannelInSetup(entry.meta)),
installedCatalogEntries,
installableCatalogEntries,
installedCatalogById: new Map(

View File

@@ -1,4 +1,5 @@
import { loadAuthProfileStore } from "../../agents/auth-profiles.js";
import { isChannelVisibleInConfiguredLists } from "../../channels/plugins/exposure.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "../../channels/plugins/types.js";
@@ -47,7 +48,7 @@ function formatLinked(value: boolean): string {
}
function shouldShowConfigured(channel: ChannelPlugin): boolean {
return channel.meta.showConfigured !== false;
return isChannelVisibleInConfiguredLists(channel.meta);
}
function formatAccountLine(params: {

View File

@@ -5,6 +5,7 @@ import { CONFIG_PATH } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
import { shouldShowChannelInSetup } from "./channel-setup/discovery.js";
import { confirm, select } from "./configure.shared.js";
import { guardCancel } from "./onboard-helpers.js";
@@ -17,6 +18,7 @@ export async function removeChannelConfigWizard(
const listConfiguredChannels = () =>
listChannelPlugins()
.map((plugin) => plugin.meta)
.filter((meta) => shouldShowChannelInSetup(meta))
.filter((meta) => next.channels?.[meta.id] !== undefined);
while (true) {

View File

@@ -818,6 +818,51 @@ describe("setupChannels", () => {
expect(multiselect).not.toHaveBeenCalled();
});
it("hides channels marked hidden from setup in the picker", async () => {
const qaChannelBase = createChannelTestPluginBase({
id: "qa-channel",
label: "QA Channel",
docsPath: "/channels/qa-channel",
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "qa-channel",
source: "test",
plugin: {
...qaChannelBase,
meta: {
...qaChannelBase.meta,
showInSetup: false,
},
},
},
]),
);
const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => {
if (message === "Select a channel") {
expect(
(options as Array<{ label?: string }>).some((option) =>
option.label?.includes("QA Channel"),
),
).toBe(false);
}
return "__done__";
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
await runSetupChannels({} as OpenClawConfig, prompter);
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
expect(multiselect).not.toHaveBeenCalled();
});
it("treats installed external plugin channels as installed without reinstall prompts", async () => {
setActivePluginRegistry(createEmptyPluginRegistry());
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([createMSTeamsCatalogEntry()]);

View File

@@ -9,6 +9,7 @@ import {
} from "../channels/registry.js";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveChannelSetupEntries } from "../commands/channel-setup/discovery.js";
import { shouldShowChannelInSetup } from "../commands/channel-setup/discovery.js";
import { resolveChannelSetupWizardAdapterForPlugin } from "../commands/channel-setup/registry.js";
import type {
ChannelSetupWizardAdapter,
@@ -79,6 +80,9 @@ export async function collectChannelStatus(params: {
));
const statusEntries = await Promise.all(
installedPlugins.flatMap((plugin) => {
if (!shouldShowChannelInSetup(plugin.meta)) {
return [];
}
const adapter = resolveAdapter(plugin.id);
if (!adapter) {
return [];
@@ -92,6 +96,7 @@ export async function collectChannelStatus(params: {
);
const statusByChannel = new Map(statusEntries.map((entry) => [entry.channel, entry]));
const fallbackStatuses = listChatChannels()
.filter((meta) => shouldShowChannelInSetup(meta))
.filter((meta) => !statusByChannel.has(meta.id))
.map((meta) => {
const configured = isChannelConfigured(params.cfg, meta.id);
@@ -235,22 +240,31 @@ export function resolveChannelSelectionNoteLines(params: {
export function resolveChannelSetupSelectionContributions(params: {
entries: Array<{
id: ChannelChoice;
meta: { id: string; label: string; selectionLabel?: string };
meta: {
id: string;
label: string;
selectionLabel?: string;
exposure?: { setup?: boolean };
showConfigured?: boolean;
showInSetup?: boolean;
};
}>;
statusByChannel: Map<ChannelChoice, { selectionHint?: string }>;
resolveDisabledHint: (channel: ChannelChoice) => string | undefined;
}): ChannelSetupSelectionContribution[] {
return params.entries.map((entry) => {
const disabledHint = params.resolveDisabledHint(entry.id);
const hint =
[params.statusByChannel.get(entry.id)?.selectionHint, disabledHint]
.filter(Boolean)
.join(" · ") || undefined;
return buildChannelSetupSelectionContribution({
channel: entry.id,
label: entry.meta.selectionLabel ?? entry.meta.label,
hint,
source: listChatChannels().some((channel) => channel.id === entry.id) ? "core" : "plugin",
return params.entries
.filter((entry) => shouldShowChannelInSetup(entry.meta))
.map((entry) => {
const disabledHint = params.resolveDisabledHint(entry.id);
const hint =
[params.statusByChannel.get(entry.id)?.selectionHint, disabledHint]
.filter(Boolean)
.join(" · ") || undefined;
return buildChannelSetupSelectionContribution({
channel: entry.id,
label: entry.meta.selectionLabel ?? entry.meta.label,
hint,
source: listChatChannels().some((channel) => channel.id === entry.id) ? "core" : "plugin",
});
});
});
}

View File

@@ -8,7 +8,10 @@ import {
import type { ChannelSetupPlugin } from "../channels/plugins/setup-wizard-types.js";
import { listChatChannels } from "../channels/registry.js";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveChannelSetupEntries } from "../commands/channel-setup/discovery.js";
import {
resolveChannelSetupEntries,
shouldShowChannelInSetup,
} from "../commands/channel-setup/discovery.js";
import {
ensureChannelSetupPluginInstalled,
loadChannelSetupPluginRegistrySnapshotForChannel,
@@ -98,10 +101,14 @@ export async function setupChannels(
const listVisibleInstalledPlugins = (): ChannelSetupPlugin[] => {
const merged = new Map<string, ChannelSetupPlugin>();
for (const plugin of listChannelSetupPlugins()) {
merged.set(plugin.id, plugin);
if (shouldShowChannelInSetup(plugin.meta)) {
merged.set(plugin.id, plugin);
}
}
for (const plugin of scopedPluginsById.values()) {
merged.set(plugin.id, plugin);
if (shouldShowChannelInSetup(plugin.meta)) {
merged.set(plugin.id, plugin);
}
}
return Array.from(merged.values());
};
@@ -181,11 +188,13 @@ export async function setupChannels(
return cfg;
}
const corePrimer = listChatChannels().map((meta) => ({
id: meta.id,
label: meta.label,
blurb: meta.blurb,
}));
const corePrimer = listChatChannels()
.filter((meta) => shouldShowChannelInSetup(meta))
.map((meta) => ({
id: meta.id,
label: meta.label,
blurb: meta.blurb,
}));
const coreIds = new Set(corePrimer.map((entry) => entry.id));
const primerChannels = [
...corePrimer,

View File

@@ -1,5 +1,6 @@
import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "../channels/ids.js";
import { emptyChannelConfigSchema } from "../channels/plugins/config-schema.js";
import { resolveChannelExposure } from "../channels/plugins/exposure.js";
import { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js";
import {
createScopedAccountReplyToModeResolver,
@@ -226,6 +227,7 @@ function toSdkChatChannelMeta(params: {
if (!label) {
throw new Error(`Missing label for bundled chat channel "${params.id}"`);
}
const exposure = resolveChannelExposure(params.channel);
return {
id: params.id,
label,
@@ -253,9 +255,7 @@ function toSdkChatChannelMeta(params: {
...(params.channel.markdownCapable !== undefined
? { markdownCapable: params.channel.markdownCapable }
: {}),
...(params.channel.showConfigured !== undefined
? { showConfigured: params.channel.showConfigured }
: {}),
exposure,
...(params.channel.quickstartAllowFrom !== undefined
? { quickstartAllowFrom: params.channel.quickstartAllowFrom }
: {}),

View File

@@ -0,0 +1,40 @@
// Narrow plugin-sdk surface for the bundled qa-channel plugin.
// Keep this list additive and scoped to QA transport contracts.
export {
buildQaTarget,
buildQaTarget as formatQaTarget,
createQaBusThread,
deleteQaBusMessage,
editQaBusMessage,
getQaBusState,
injectQaBusInboundMessage,
normalizeQaTarget,
parseQaTarget,
pollQaBus,
qaChannelPlugin,
reactToQaBusMessage,
readQaBusMessage,
searchQaBusMessages,
sendQaBusMessage,
setQaChannelRuntime,
} from "../../extensions/qa-channel/api.js";
export type {
QaBusConversation,
QaBusConversationKind,
QaBusCreateThreadInput,
QaBusDeleteMessageInput,
QaBusEditMessageInput,
QaBusEvent,
QaBusInboundMessageInput,
QaBusMessage,
QaBusOutboundMessageInput,
QaBusPollInput,
QaBusPollResult,
QaBusReactToMessageInput,
QaBusReadMessageInput,
QaBusSearchMessagesInput,
QaBusStateSnapshot,
QaBusThread,
QaBusWaitForInput,
} from "../../extensions/qa-channel/api.js";

View File

@@ -429,7 +429,13 @@ export type PluginPackageChannel = {
selectionDocsOmitLabel?: boolean;
selectionExtras?: readonly string[];
markdownCapable?: boolean;
exposure?: {
configured?: boolean;
setup?: boolean;
docs?: boolean;
};
showConfigured?: boolean;
showInSetup?: boolean;
quickstartAllowFrom?: boolean;
forceAccountBinding?: boolean;
preferSessionLookupForAnnounceTarget?: boolean;

View File

@@ -0,0 +1 @@
export * from "../../extensions/qa-lab/api.js";

1
src/qa-e2e/bus-server.ts Normal file
View File

@@ -0,0 +1 @@
export * from "../../extensions/qa-lab/api.js";

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { createQaBusState } from "./bus-state.js";
describe("qa-bus state", () => {
it("records inbound and outbound traffic in cursor order", () => {
const state = createQaBusState();
const inbound = state.addInboundMessage({
conversation: { id: "alice", kind: "direct" },
senderId: "alice",
text: "hello",
});
const outbound = state.addOutboundMessage({
to: "dm:alice",
text: "hi",
});
const snapshot = state.getSnapshot();
expect(snapshot.cursor).toBe(2);
expect(snapshot.events.map((event) => event.kind)).toEqual([
"inbound-message",
"outbound-message",
]);
expect(snapshot.messages.map((message) => message.id)).toEqual([inbound.id, outbound.id]);
});
it("creates threads and mutates message state", async () => {
const state = createQaBusState();
const thread = state.createThread({
conversationId: "qa-room",
title: "QA thread",
});
const message = state.addOutboundMessage({
to: `thread:qa-room/${thread.id}`,
text: "inside thread",
threadId: thread.id,
});
state.reactToMessage({
messageId: message.id,
emoji: "white_check_mark",
});
state.editMessage({
messageId: message.id,
text: "inside thread (edited)",
});
state.deleteMessage({
messageId: message.id,
});
const updated = state.readMessage({ messageId: message.id });
expect(updated.threadId).toBe(thread.id);
expect(updated.reactions).toHaveLength(1);
expect(updated.text).toContain("(edited)");
expect(updated.deleted).toBe(true);
const waited = await state.waitFor({
kind: "thread-id",
threadId: thread.id,
timeoutMs: 50,
});
expect("id" in waited && waited.id).toBe(thread.id);
});
it("replays fresh events after a reset rewinds the cursor", () => {
const state = createQaBusState();
state.addInboundMessage({
conversation: { id: "alice", kind: "direct" },
senderId: "alice",
text: "before reset",
});
const beforeReset = state.poll({
accountId: "default",
cursor: 0,
});
expect(beforeReset.events).toHaveLength(1);
state.reset();
state.addInboundMessage({
conversation: { id: "alice", kind: "direct" },
senderId: "alice",
text: "after reset",
});
const afterReset = state.poll({
accountId: "default",
cursor: beforeReset.cursor,
});
expect(afterReset.events).toHaveLength(1);
expect(afterReset.events[0]?.kind).toBe("inbound-message");
expect(
afterReset.events[0] &&
"message" in afterReset.events[0] &&
afterReset.events[0].message.text,
).toBe("after reset");
});
});

1
src/qa-e2e/bus-state.ts Normal file
View File

@@ -0,0 +1 @@
export * from "../../extensions/qa-lab/api.js";

View File

@@ -0,0 +1 @@
export * from "../../extensions/qa-lab/api.js";

View File

@@ -0,0 +1 @@
export * from "../../extensions/qa-lab/api.js";

1
src/qa-e2e/report.ts Normal file
View File

@@ -0,0 +1 @@
export * from "../../extensions/qa-lab/api.js";

1
src/qa-e2e/runner.ts Normal file
View File

@@ -0,0 +1 @@
export * from "../../extensions/qa-lab/api.js";

1
src/qa-e2e/scenario.ts Normal file
View File

@@ -0,0 +1 @@
export * from "../../extensions/qa-lab/api.js";

View File

@@ -0,0 +1 @@
export * from "../../extensions/qa-lab/api.js";