feat(agentctl): expectIpcError, ban wait, automod dry-run assert keys

Support headless moderation scenarios: IPC error expectations, DB ban wait,
automodBlocked sidecar match, and view wait keys for automod/timeouts.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-05-23 16:33:52 -04:00
co-authored by Cursor
parent 8bb79defdd
commit 398d453c57
3 changed files with 54 additions and 1 deletions
+2
View File
@@ -17,6 +17,8 @@ Both modes use **`pearcord-ui-flow` `dispatchUiMessage`** — the same handler a
`AgentClient` resolves RPC replies before treating `{ event: "state" | "sidecar" }` push lines as events (fixes `wait-event` responses that include an `event` field). `AgentClient` resolves RPC replies before treating `{ event: "state" | "sidecar" }` push lines as events (fixes `wait-event` responses that include an `event` field).
**v0.8.390 (Phase 427):** `expectIpcError` step, `wait.banForUserId` (DB ban row), `waitEvent` with `automodBlocked`, assert keys `automodEnabled`, `automodBlockedKeywordsMin`, `memberTimeoutFor`. Bundle: `npm run test:agentctl-phase427-moderation`.
## Quick start ## Quick start
### 1. Start Pearcord with the control server ### 1. Start Pearcord with the control server
+17
View File
@@ -144,6 +144,20 @@ function matchView (view, spec) {
if ((view?.activeChannelOverwrites || []).length < Number(expected)) return false if ((view?.activeChannelOverwrites || []).length < Number(expected)) return false
continue continue
} }
if (key === 'automodEnabled') {
if (!!view?.automodConfig?.enabled !== !!expected) return false
continue
}
if (key === 'automodBlockedKeywordsMin') {
if ((view?.automodConfig?.blockedKeywords || []).length < Number(expected)) {
return false
}
continue
}
if (key === 'memberTimeoutFor') {
if (!view?.memberTimeouts?.[String(expected)]) return false
continue
}
const actual = getPath(view, key) const actual = getPath(view, key)
if ( if (
expected && expected &&
@@ -176,6 +190,9 @@ function matchSidecarEvent (evt, spec) {
const msg = payload.message || payload.record?.msg || '' const msg = payload.message || payload.record?.msg || ''
if (!String(msg).includes(String(spec.messageIncludes))) return false if (!String(msg).includes(String(spec.messageIncludes))) return false
} }
if (spec.automodBlocked) {
if (payload.result?.ok !== false) return false
}
return true return true
} }
+35 -1
View File
@@ -90,7 +90,22 @@ class HeadlessSession {
const deadline = Date.now() + timeoutMs const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) { while (Date.now() < deadline) {
await this.refreshView() await this.refreshView()
if (matchView(this._lastView, match)) return this._lastView if (match?.banForUserId) {
const guildId = this._lastView?.guild?.id
const userId = String(match.banForUserId)
if (guildId) {
const ban = await this.platform.db.get(COLLECTIONS.BANS, { guildId, userId })
if (ban) {
const rest = { ...match }
delete rest.banForUserId
if (!Object.keys(rest).length || matchView(this._lastView, rest)) {
return this._lastView
}
}
}
} else if (matchView(this._lastView, match)) {
return this._lastView
}
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
} }
throw new Error(`headless wait timeout: ${JSON.stringify(match)}`) throw new Error(`headless wait timeout: ${JSON.stringify(match)}`)
@@ -102,6 +117,25 @@ class HeadlessSession {
async runSteps (steps) { async runSteps (steps) {
const results = [] const results = []
for (const step of steps || []) { for (const step of steps || []) {
if (step.expectIpcError) {
const spec =
step.expectIpcError && typeof step.expectIpcError === 'object'
? step.expectIpcError
: {}
const payload = spec.ipc || spec
let err = null
try {
await this.ipc(payload)
} catch (e) {
err = e
}
if (!err) throw new Error(`expected ipc error for ${payload?.type}`)
const msg = String(err.message || err)
if (spec.messageIncludes && !msg.includes(String(spec.messageIncludes))) {
throw new Error(`expected "${spec.messageIncludes}" in: ${msg}`)
}
results.push({ expectIpcError: payload, error: msg })
}
if (step.ipc) { if (step.ipc) {
const view = await this.ipc(step.ipc) const view = await this.ipc(step.ipc)
results.push({ ipc: step.ipc, view }) results.push({ ipc: step.ipc, view })