mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-17 11:38:44 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ec621ab58 | ||
|
|
8762df9bb4 | ||
|
|
dce0467826 | ||
|
|
258484854b |
11
.github/labeler.yml
vendored
11
.github/labeler.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -147,3 +147,5 @@ changelog/fragments/
|
||||
.tmp/
|
||||
test/fixtures/openclaw-vitest-unit-report.json
|
||||
analysis/
|
||||
.artifacts/qa-e2e/
|
||||
extensions/qa-lab/web/dist/
|
||||
|
||||
99
docs/channels/qa-channel.md
Normal file
99
docs/channels/qa-channel.md
Normal 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
|
||||
865
docs/concepts/qa-e2e-automation.md
Normal file
865
docs/concepts/qa-e2e-automation.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
5
extensions/qa-channel/api.ts
Normal file
5
extensions/qa-channel/api.ts
Normal 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";
|
||||
15
extensions/qa-channel/index.ts
Normal file
15
extensions/qa-channel/index.ts
Normal 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,
|
||||
});
|
||||
9
extensions/qa-channel/openclaw.plugin.json
Normal file
9
extensions/qa-channel/openclaw.plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "qa-channel",
|
||||
"channels": ["qa-channel"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
45
extensions/qa-channel/package.json
Normal file
45
extensions/qa-channel/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
extensions/qa-channel/runtime-api.ts
Normal file
1
extensions/qa-channel/runtime-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src/runtime-api.js";
|
||||
4
extensions/qa-channel/setup-entry.ts
Normal file
4
extensions/qa-channel/setup-entry.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { qaChannelPlugin } from "./src/channel.js";
|
||||
|
||||
export default defineSetupPluginEntry(qaChannelPlugin);
|
||||
61
extensions/qa-channel/src/accounts.ts
Normal file
61
extensions/qa-channel/src/accounts.ts
Normal 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";
|
||||
224
extensions/qa-channel/src/bus-client.ts
Normal file
224
extensions/qa-channel/src/bus-client.ts
Normal 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;
|
||||
}
|
||||
193
extensions/qa-channel/src/channel-actions.ts
Normal file
193
extensions/qa-channel/src/channel-actions.ts
Normal 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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
225
extensions/qa-channel/src/channel.test.ts
Normal file
225
extensions/qa-channel/src/channel.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
112
extensions/qa-channel/src/channel.ts
Normal file
112
extensions/qa-channel/src/channel.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
32
extensions/qa-channel/src/config-schema.ts
Normal file
32
extensions/qa-channel/src/config-schema.ts
Normal 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);
|
||||
55
extensions/qa-channel/src/gateway.ts
Normal file
55
extensions/qa-channel/src/gateway.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
124
extensions/qa-channel/src/inbound.ts
Normal file
124
extensions/qa-channel/src/inbound.ts
Normal 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)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
34
extensions/qa-channel/src/outbound.ts
Normal file
34
extensions/qa-channel/src/outbound.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
180
extensions/qa-channel/src/protocol.ts
Normal file
180
extensions/qa-channel/src/protocol.ts
Normal 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;
|
||||
};
|
||||
23
extensions/qa-channel/src/runtime-api.ts
Normal file
23
extensions/qa-channel/src/runtime-api.ts
Normal 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";
|
||||
7
extensions/qa-channel/src/runtime.ts
Normal file
7
extensions/qa-channel/src/runtime.ts
Normal 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 };
|
||||
40
extensions/qa-channel/src/setup.ts
Normal file
40
extensions/qa-channel/src/setup.ts
Normal 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;
|
||||
}
|
||||
23
extensions/qa-channel/src/status.ts
Normal file
23
extensions/qa-channel/src/status.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
44
extensions/qa-channel/src/types.ts
Normal file
44
extensions/qa-channel/src/types.ts
Normal 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;
|
||||
};
|
||||
2
extensions/qa-channel/test-api.ts
Normal file
2
extensions/qa-channel/test-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./src/protocol.js";
|
||||
export * from "./src/bus-client.js";
|
||||
10
extensions/qa-lab/api.ts
Normal file
10
extensions/qa-lab/api.ts
Normal 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";
|
||||
24
extensions/qa-lab/index.ts
Normal file
24
extensions/qa-lab/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
8
extensions/qa-lab/openclaw.plugin.json
Normal file
8
extensions/qa-lab/openclaw.plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "qa-lab",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
31
extensions/qa-lab/package.json
Normal file
31
extensions/qa-lab/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
extensions/qa-lab/runtime-api.ts
Normal file
1
extensions/qa-lab/runtime-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src/runtime-api.js";
|
||||
137
extensions/qa-lab/src/bus-queries.ts
Normal file
137
extensions/qa-lab/src/bus-queries.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
179
extensions/qa-lab/src/bus-server.ts
Normal file
179
extensions/qa-lab/src/bus-server.ts
Normal 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())),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
257
extensions/qa-lab/src/bus-state.ts
Normal file
257
extensions/qa-lab/src/bus-state.ts
Normal 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>;
|
||||
87
extensions/qa-lab/src/bus-waiters.ts
Normal file
87
extensions/qa-lab/src/bus-waiters.ts
Normal 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);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
37
extensions/qa-lab/src/cli.runtime.ts
Normal file
37
extensions/qa-lab/src/cli.runtime.ts
Normal 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);
|
||||
}
|
||||
41
extensions/qa-lab/src/cli.ts
Normal file
41
extensions/qa-lab/src/cli.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
31
extensions/qa-lab/src/extract-tool-payload.ts
Normal file
31
extensions/qa-lab/src/extract-tool-payload.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
75
extensions/qa-lab/src/harness-runtime.ts
Normal file
75
extensions/qa-lab/src/harness-runtime.ts
Normal 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;
|
||||
}
|
||||
67
extensions/qa-lab/src/lab-server.test.ts
Normal file
67
extensions/qa-lab/src/lab-server.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
289
extensions/qa-lab/src/lab-server.ts
Normal file
289
extensions/qa-lab/src/lab-server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
91
extensions/qa-lab/src/report.ts
Normal file
91
extensions/qa-lab/src/report.ts
Normal 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");
|
||||
}
|
||||
38
extensions/qa-lab/src/runtime-api.ts
Normal file
38
extensions/qa-lab/src/runtime-api.ts
Normal 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";
|
||||
65
extensions/qa-lab/src/scenario.ts
Normal file
65
extensions/qa-lab/src/scenario.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
121
extensions/qa-lab/src/self-check-scenario.ts
Normal file
121
extensions/qa-lab/src/self-check-scenario.ts
Normal 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");
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
91
extensions/qa-lab/src/self-check.ts
Normal file
91
extensions/qa-lab/src/self-check.ts
Normal 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;
|
||||
12
extensions/qa-lab/web/index.html
Normal file
12
extensions/qa-lab/web/index.html
Normal 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>
|
||||
498
extensions/qa-lab/web/src/app.ts
Normal file
498
extensions/qa-lab/web/src/app.ts
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
10
extensions/qa-lab/web/src/main.ts
Normal file
10
extensions/qa-lab/web/src/main.ts
Normal 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);
|
||||
305
extensions/qa-lab/web/src/styles.css
Normal file
305
extensions/qa-lab/web/src/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
11
extensions/qa-lab/web/vite.config.ts
Normal file
11
extensions/qa-lab/web/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
6
scripts/qa-e2e.ts
Normal 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`);
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
29
src/channels/plugins/exposure.ts
Normal file
29
src/channels/plugins/exposure.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
40
src/plugin-sdk/qa-channel.ts
Normal file
40
src/plugin-sdk/qa-channel.ts
Normal 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";
|
||||
@@ -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;
|
||||
|
||||
1
src/qa-e2e/bus-queries.ts
Normal file
1
src/qa-e2e/bus-queries.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../extensions/qa-lab/api.js";
|
||||
1
src/qa-e2e/bus-server.ts
Normal file
1
src/qa-e2e/bus-server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../extensions/qa-lab/api.js";
|
||||
99
src/qa-e2e/bus-state.test.ts
Normal file
99
src/qa-e2e/bus-state.test.ts
Normal 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
1
src/qa-e2e/bus-state.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../extensions/qa-lab/api.js";
|
||||
1
src/qa-e2e/bus-waiters.ts
Normal file
1
src/qa-e2e/bus-waiters.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../extensions/qa-lab/api.js";
|
||||
1
src/qa-e2e/harness-runtime.ts
Normal file
1
src/qa-e2e/harness-runtime.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../extensions/qa-lab/api.js";
|
||||
1
src/qa-e2e/report.ts
Normal file
1
src/qa-e2e/report.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../extensions/qa-lab/api.js";
|
||||
1
src/qa-e2e/runner.ts
Normal file
1
src/qa-e2e/runner.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../extensions/qa-lab/api.js";
|
||||
1
src/qa-e2e/scenario.ts
Normal file
1
src/qa-e2e/scenario.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../extensions/qa-lab/api.js";
|
||||
1
src/qa-e2e/self-check-scenario.ts
Normal file
1
src/qa-e2e/self-check-scenario.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../extensions/qa-lab/api.js";
|
||||
Reference in New Issue
Block a user