feat(agentctl): channel search cache scenario and channel selectors

Add selectChannelOtherThanActive, searchResultsCached assert, and
document headless steps for channel-switch search cache validation.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-05-23 07:46:57 -04:00
co-authored by Cursor
parent 95b54a9972
commit 4b54f30d84
3 changed files with 51 additions and 1 deletions
+2 -1
View File
@@ -154,10 +154,11 @@ Bundled scenarios under `apps/pearcord/scenarios/`:
| `agentctl-mark-read-mention-alerts.json` | Seed mention alert + `markActiveChannelRead` clears `mentionAlerts` (v0.8.264) | | `agentctl-mark-read-mention-alerts.json` | Seed mention alert + `markActiveChannelRead` clears `mentionAlerts` (v0.8.264) |
| `agentctl-forum-channel-cache.json` | Forum search then switch forum → empty hits (v0.8.265) | | `agentctl-forum-channel-cache.json` | Forum search then switch forum → empty hits (v0.8.265) |
| `agentctl-thread-parent-perms.json` | Thread exposes `parentChannelPermissions` in view (v0.8.265) | | `agentctl-thread-parent-perms.json` | Thread exposes `parentChannelPermissions` in view (v0.8.265) |
| `agentctl-channel-search-cache.json` | Channel search empty after switching text channels (v0.8.266) |
`mesh.connectGuildMeshThread(scenarioPath)` — host steps + invite + `create-thread` + reply; guest must receive `threadReply` token. `mesh.connectGuildMeshThread(scenarioPath)` — host steps + invite + `create-thread` + reply; guest must receive `threadReply` token.
Scenario step extras (headless + attached `run`): `joinFirstVoice`, `openDmPeer`, `selectFirstForumChannel`, `selectSecondForumChannel`, `markActiveChannelRead`, `seedMentionAlertForActiveChannel`, `createThreadFromLastMessage`, `markThreadAndParentRead`, `messagesMin`, `messageContentIncludes`, `searchResultsMin`, `searchResultsMax`, `mentionAlertCountMax`, `parentChannelPermissions` on `wait`. Scenario step extras (headless + attached `run`): `joinFirstVoice`, `openDmPeer`, `selectFirstForumChannel`, `selectSecondForumChannel`, `selectFirstTextChannel`, `selectSecondTextChannel`, `selectChannelNamed`, `selectChannelOtherThanActive`, `markActiveChannelRead`, `seedMentionAlertForActiveChannel`, `createThreadFromLastMessage`, `markThreadAndParentRead`, `messagesMin`, `messageContentIncludes`, `searchResultsMin`, `searchResultsMax`, `searchResultsCached`, `mentionAlertCountMax`, `activeChannelName`, `parentChannelPermissions` on `wait`.
## Dependencies ## Dependencies
+11
View File
@@ -40,6 +40,17 @@ function matchView (view, spec) {
if ((view?.searchResults || []).length > Number(expected)) return false if ((view?.searchResults || []).length > Number(expected)) return false
continue continue
} }
if (key === 'activeChannelName') {
const ch = (view?.channels || []).find((c) => c.id === view?.activeChannelId)
if (!ch || String(ch.name || '').toLowerCase() !== String(expected).toLowerCase()) {
return false
}
continue
}
if (key === 'searchResultsCached') {
if (!!view?.searchResultsCached !== !!expected) return false
continue
}
if (key === 'mentionAlertCountMax') { if (key === 'mentionAlertCountMax') {
const alerts = view?.mentionAlerts || {} const alerts = view?.mentionAlerts || {}
const max = Object.values(alerts).reduce( const max = Object.values(alerts).reduce(
+38
View File
@@ -133,6 +133,44 @@ class HeadlessSession {
const view = await this.ipc({ type: 'select-channel', channelId: ch.id }) const view = await this.ipc({ type: 'select-channel', channelId: ch.id })
results.push({ selectSecondForumChannel: true, channelId: ch.id, view }) results.push({ selectSecondForumChannel: true, channelId: ch.id, view })
} }
if (step.selectFirstTextChannel) {
const text = (this._lastView?.channels || []).filter(
(c) => c.type === 'text' || c.type === 'announcement'
)
if (!text.length) throw new Error('no text channel in view')
const ch = text[0]
const view = await this.ipc({ type: 'select-channel', channelId: ch.id })
results.push({ selectFirstTextChannel: true, channelId: ch.id, view })
}
if (step.selectChannelNamed) {
const name = String(step.selectChannelNamed || '').trim()
const ch = (this._lastView?.channels || []).find(
(c) => String(c.name || '').toLowerCase() === name.toLowerCase()
)
if (!ch) throw new Error(`no channel named ${name}`)
const view = await this.ipc({ type: 'select-channel', channelId: ch.id })
results.push({ selectChannelNamed: name, channelId: ch.id, view })
}
if (step.selectChannelOtherThanActive) {
const active = this._lastView?.activeChannelId
const text = (this._lastView?.channels || []).filter(
(c) =>
(c.type === 'text' || c.type === 'announcement') && c.id && c.id !== active
)
if (!text.length) throw new Error('no other text channel to select')
const ch = text[0]
const view = await this.ipc({ type: 'select-channel', channelId: ch.id })
results.push({ selectChannelOtherThanActive: true, channelId: ch.id, view })
}
if (step.selectSecondTextChannel) {
const text = (this._lastView?.channels || []).filter(
(c) => c.type === 'text' || c.type === 'announcement'
)
if (text.length < 2) throw new Error('need at least two text channels')
const ch = text[1]
const view = await this.ipc({ type: 'select-channel', channelId: ch.id })
results.push({ selectSecondTextChannel: true, channelId: ch.id, view })
}
if (step.markActiveChannelRead) { if (step.markActiveChannelRead) {
const chId = this._lastView?.activeChannelId const chId = this._lastView?.activeChannelId
if (!chId) throw new Error('no active channel for mark-channel-read') if (!chId) throw new Error('no active channel for mark-channel-read')