openvisio-agent 0.19.12 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/USER_GUIDE.md +174 -0
- package/bin/cli.mjs +9 -0
- package/package.json +7 -3
- package/scenarios/index.mjs +16 -0
- package/scenarios/routing.scenarios.mjs +348 -0
- package/scenarios/runtime.scenarios.mjs +451 -0
- package/scenarios/transport.scenarios.mjs +407 -0
- package/scenarios/workspace.scenarios.mjs +324 -0
- package/scripts/certify.mjs +9 -6
- package/scripts/run-scenarios.mjs +32 -0
- package/src/agent-journal.mjs +138 -0
- package/src/assignment-routing.mjs +19 -7
- package/src/authorization-resume.mjs +1 -1
- package/src/channel-routing.mjs +15 -8
- package/src/codex-mcp-proxy.mjs +63 -18
- package/src/concurrency.mjs +5 -3
- package/src/cycle-queue.mjs +16 -3
- package/src/events.mjs +89 -38
- package/src/mastra-harness.mjs +128 -22
- package/src/mcp-http.mjs +72 -19
- package/src/memory.mjs +15 -2
- package/src/pr-push.mjs +21 -8
- package/src/runner-pool.mjs +14 -3
- package/src/studio-cli.mjs +60 -0
- package/src/studio-server.mjs +224 -0
- package/src/task-types.mjs +3 -2
- package/src/thread-context.mjs +38 -3
- package/src/watch.mjs +345 -116
- package/src/ws.mjs +20 -1
- package/studio/app.mjs +658 -0
- package/studio/guide.html +122 -0
- package/studio/index.html +61 -0
- package/studio/style.css +223 -0
package/src/channel-routing.mjs
CHANGED
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
const normalize = (name) => String(name || '').toLowerCase().replace(/^#/, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
2
|
-
const validId = (id) => id
|
|
2
|
+
const validId = (id) => (typeof id === 'number' || (typeof id === 'string' && /^\d+$/.test(id))) && Number.isSafeInteger(Number(id)) && Number(id) > 0
|
|
3
3
|
|
|
4
4
|
export function repliesAfterSource(messages, sourceMessageId) {
|
|
5
|
-
|
|
5
|
+
const rows = Array.isArray(messages) ? messages : []
|
|
6
|
+
if (sourceMessageId == null) return rows
|
|
6
7
|
// Backend message ids are monotonically increasing database ids. For opaque
|
|
7
8
|
// ids, rely on the persisted source delivery key rather than suppressing a
|
|
8
9
|
// new question because the agent replied to an earlier one in this thread.
|
|
9
10
|
if (!/^\d+$/.test(String(sourceMessageId))) return []
|
|
10
|
-
return
|
|
11
|
+
return rows.filter((message) => message && /^\d+$/.test(String(message.id)) && BigInt(message.id) > BigInt(sourceMessageId))
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export function dedicatedChannel(channels, { id, identifier, aliases = [], channelId } = {}) {
|
|
14
|
-
const usable = channels.filter((channel) => validId(channel?.id))
|
|
15
|
+
const usable = (Array.isArray(channels) ? channels : []).filter((channel) => validId(channel?.id))
|
|
16
|
+
if (channelId != null && !validId(channelId)) return null
|
|
15
17
|
if (validId(channelId)) return usable.find((channel) => Number(channel.id) === Number(channelId)) || null
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
const ownerId = (channel) => channel.agent_id ?? channel.agentId
|
|
19
|
+
const ownerIdentifier = (channel) => channel.agent_identifier ?? channel.agentIdentifier
|
|
20
|
+
const owned = usable.filter((channel) => {
|
|
21
|
+
if (id != null && ownerId(channel) != null) return String(ownerId(channel)) === String(id)
|
|
22
|
+
return identifier && ownerIdentifier(channel) === identifier
|
|
23
|
+
})
|
|
19
24
|
if (owned.length) return owned.length === 1 ? owned[0] : null
|
|
20
25
|
const names = new Set([identifier, ...aliases].filter(Boolean).flatMap((alias) => {
|
|
21
26
|
const name = normalize(alias)
|
|
22
27
|
return [name, `agent-${name}`, `${name}-agent`]
|
|
23
28
|
}).filter((name) => !['general', 'team', 'project', 'dev', 'development'].includes(name)))
|
|
24
|
-
|
|
29
|
+
// Explicit ownership always outranks a similar name. A recycled agent name
|
|
30
|
+
// must never route a new thread into somebody else's dedicated channel.
|
|
31
|
+
const matches = usable.filter((channel) => ownerId(channel) == null && !ownerIdentifier(channel) && names.has(normalize(channel.name)))
|
|
25
32
|
return matches.length === 1 ? matches[0] : null
|
|
26
33
|
}
|
package/src/codex-mcp-proxy.mjs
CHANGED
|
@@ -5,35 +5,80 @@ import { createMcpHttpClient } from './mcp-http.mjs'
|
|
|
5
5
|
|
|
6
6
|
export function toolWithoutCredentialInputs(tool) {
|
|
7
7
|
const copy = structuredClone(tool)
|
|
8
|
-
const schema
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
for (const schema of [copy.inputSchema, copy.input_schema]) {
|
|
9
|
+
if (!schema || typeof schema !== 'object') continue
|
|
10
|
+
if (schema.properties && typeof schema.properties === 'object') {
|
|
11
|
+
delete schema.properties.agent_api_key
|
|
12
|
+
delete schema.properties.agent_identifier
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(schema.required)) {
|
|
15
|
+
schema.required = schema.required.filter((name) => name !== 'agent_api_key' && name !== 'agent_identifier')
|
|
16
|
+
}
|
|
16
17
|
}
|
|
17
18
|
return copy
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export function toolAllowed(name, { disabled = new Set(), allowed = null } = {}) {
|
|
21
22
|
const tool = String(name || '')
|
|
22
|
-
return !disabled.has(tool) && (!(allowed instanceof Set) || allowed.has(tool))
|
|
23
|
+
return !!tool && !disabled.has(tool) && (!(allowed instanceof Set) || allowed.has(tool))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function agentResultWithoutCredentials(result) {
|
|
27
|
+
const credentials = new Set()
|
|
28
|
+
const credentialFields = new Set(['apikey', 'agentapikey', 'password', 'passwordhash', 'accesstoken', 'refreshtoken', 'clientsecret', 'secret', 'token'])
|
|
29
|
+
const strip = (value) => {
|
|
30
|
+
if (Array.isArray(value)) return value.map(strip)
|
|
31
|
+
if (!value || typeof value !== 'object') return value
|
|
32
|
+
return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => {
|
|
33
|
+
if (credentialFields.has(key.replace(/[_-]/g, '').toLowerCase())) {
|
|
34
|
+
if (typeof item === 'string' && item) credentials.add(item)
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
return [[key, strip(item)]]
|
|
38
|
+
}))
|
|
39
|
+
}
|
|
40
|
+
const copy = strip(result)
|
|
41
|
+
const parsedText = new Map()
|
|
42
|
+
for (const item of Array.isArray(copy?.content) ? copy.content : []) {
|
|
43
|
+
if (item?.type !== 'text' || typeof item.text !== 'string' || !/^\s*[\[{]/.test(item.text)) continue
|
|
44
|
+
let data
|
|
45
|
+
try { data = JSON.parse(item.text) }
|
|
46
|
+
catch { throw new Error('MCP agent directory returned malformed JSON') }
|
|
47
|
+
parsedText.set(item, strip(data))
|
|
48
|
+
}
|
|
49
|
+
// The backend can include the same records in structuredContent and in a
|
|
50
|
+
// rendered markdown summary. Gather secrets first, then redact every rendering.
|
|
51
|
+
const redact = (value) => {
|
|
52
|
+
if (typeof value === 'string') {
|
|
53
|
+
for (const secret of credentials) value = value.split(secret).join('[redacted]')
|
|
54
|
+
return value
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(value)) return value.map(redact)
|
|
57
|
+
if (!value || typeof value !== 'object') return value
|
|
58
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redact(item)]))
|
|
59
|
+
}
|
|
60
|
+
for (const [item, data] of parsedText) item.text = JSON.stringify(redact(data))
|
|
61
|
+
return redact(copy)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function proxyToolPolicyFromEnvironment(env = process.env) {
|
|
65
|
+
const parse = (key, fallback) => {
|
|
66
|
+
let value
|
|
67
|
+
try { value = JSON.parse(env[key] ?? fallback) }
|
|
68
|
+
catch { throw new Error(`Invalid MCP tool policy: ${key}`) }
|
|
69
|
+
if (value !== null && (!Array.isArray(value) || value.some((name) => typeof name !== 'string' || !name))) throw new Error(`Invalid MCP tool policy: ${key}`)
|
|
70
|
+
return value
|
|
71
|
+
}
|
|
72
|
+
const disabled = parse('OPENVISIO_CODEX_DISABLED_TOOLS', '[]')
|
|
73
|
+
const allowed = parse('OPENVISIO_CODEX_ALLOWED_TOOLS', 'null')
|
|
74
|
+
return { disabled: new Set(disabled || []), allowed: allowed === null ? null : new Set(allowed) }
|
|
23
75
|
}
|
|
24
76
|
|
|
25
77
|
export function runCodexMcpProxy() {
|
|
26
78
|
const url = process.env.OPENVISIO_CODEX_MCP_URL
|
|
27
79
|
const apiKey = process.env.OPENVISIO_CODEX_API_KEY
|
|
28
80
|
const identifier = process.env.OPENVISIO_CODEX_IDENTIFIER
|
|
29
|
-
|
|
30
|
-
try { disabledTools = JSON.parse(process.env.OPENVISIO_CODEX_DISABLED_TOOLS || '[]') } catch { /* no disabled tools */ }
|
|
31
|
-
const disabled = new Set(Array.isArray(disabledTools) ? disabledTools.map(String) : [])
|
|
32
|
-
let allowedTools = null
|
|
33
|
-
try {
|
|
34
|
-
const parsed = JSON.parse(process.env.OPENVISIO_CODEX_ALLOWED_TOOLS || 'null')
|
|
35
|
-
if (Array.isArray(parsed)) allowedTools = new Set(parsed.map(String))
|
|
36
|
-
} catch { /* unrestricted except for disabled tools */ }
|
|
81
|
+
const { disabled, allowed: allowedTools } = proxyToolPolicyFromEnvironment()
|
|
37
82
|
if (!url || !apiKey || !identifier) throw new Error('OpenVisio Codex MCP bridge is missing its watcher environment.')
|
|
38
83
|
const client = createMcpHttpClient({ url, apiKey, identifier, clientVersion: 'openvisio-agent-codex-proxy' })
|
|
39
84
|
const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n')
|
|
@@ -77,7 +122,7 @@ export function runCodexMcpProxy() {
|
|
|
77
122
|
if (!(allowedTools instanceof Set) || !allowedTools.has(name) || !/^(?:list_|get_|codebase_tree$)/.test(name)) throw error
|
|
78
123
|
result = await client.callTool(name, request.params?.arguments || {})
|
|
79
124
|
}
|
|
80
|
-
reply(id, result)
|
|
125
|
+
reply(id, /^(?:list_agents|get_agent|get_agent_details)$/.test(name) ? agentResultWithoutCredentials(result) : result)
|
|
81
126
|
} else if (id != null) {
|
|
82
127
|
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } }) + '\n')
|
|
83
128
|
}
|
package/src/concurrency.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
export async function mapConcurrent(items, concurrency, run) {
|
|
2
2
|
if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error('Invalid concurrency')
|
|
3
3
|
const results = new Array(items.length)
|
|
4
|
-
let next = 0
|
|
4
|
+
let next = 0, failed = false, failure
|
|
5
5
|
await Promise.all(Array.from({ length: Math.min(items.length, concurrency) }, async () => {
|
|
6
|
-
while (next < items.length) {
|
|
6
|
+
while (!failed && next < items.length) {
|
|
7
7
|
const index = next++
|
|
8
|
-
results[index] = await run(items[index], index)
|
|
8
|
+
try { results[index] = await run(items[index], index) }
|
|
9
|
+
catch (error) { if (!failed) failure = error; failed = true }
|
|
9
10
|
}
|
|
10
11
|
}))
|
|
12
|
+
if (failed) throw failure
|
|
11
13
|
return results
|
|
12
14
|
}
|
package/src/cycle-queue.mjs
CHANGED
|
@@ -8,6 +8,9 @@ export function createCycleQueue({ run, onError = () => {}, concurrency = 1, gro
|
|
|
8
8
|
const active = new Map()
|
|
9
9
|
const activeGroups = new Set()
|
|
10
10
|
let sequence = 0
|
|
11
|
+
const report = (error, item) => {
|
|
12
|
+
try { Promise.resolve(onError(error, item)).catch(() => {}) } catch { /* logging cannot strand the queue */ }
|
|
13
|
+
}
|
|
11
14
|
|
|
12
15
|
const pump = () => {
|
|
13
16
|
while (active.size < concurrency && pending.size) {
|
|
@@ -24,7 +27,7 @@ export function createCycleQueue({ run, onError = () => {}, concurrency = 1, gro
|
|
|
24
27
|
void (async () => {
|
|
25
28
|
try { entry.resolve(await run(entry.item)) }
|
|
26
29
|
catch (error) {
|
|
27
|
-
|
|
30
|
+
report(error, entry.item)
|
|
28
31
|
entry.resolve({ status: 'failed' })
|
|
29
32
|
} finally {
|
|
30
33
|
active.delete(key)
|
|
@@ -41,7 +44,8 @@ export function createCycleQueue({ run, onError = () => {}, concurrency = 1, gro
|
|
|
41
44
|
if (pending.has(key)) return pending.get(key).promise
|
|
42
45
|
let resolve
|
|
43
46
|
const promise = new Promise((done) => { resolve = done })
|
|
44
|
-
const
|
|
47
|
+
const rawGroup = groupKey(item)
|
|
48
|
+
const group = rawGroup == null ? '' : String(rawGroup)
|
|
45
49
|
pending.set(key, { item, promise, resolve, group })
|
|
46
50
|
pump()
|
|
47
51
|
return promise
|
|
@@ -52,7 +56,16 @@ export function createCycleQueue({ run, onError = () => {}, concurrency = 1, gro
|
|
|
52
56
|
pending.delete(key)
|
|
53
57
|
entry.resolve({ status: 'canceled' })
|
|
54
58
|
}
|
|
55
|
-
for (const entry of active.values())
|
|
59
|
+
for (const entry of active.values()) {
|
|
60
|
+
if (!predicate(entry.item)) continue
|
|
61
|
+
try { Promise.resolve(cancelActive(entry.item)).catch((error) => report(error, entry.item)) }
|
|
62
|
+
catch (error) { report(error, entry.item) }
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
async settled() {
|
|
66
|
+
// Capture work already accepted. Shutdown first stops intake and cancels
|
|
67
|
+
// pending entries, then waits for active workers to finish their cleanup.
|
|
68
|
+
await Promise.all([...active.values(), ...pending.values()].map((entry) => entry.promise))
|
|
56
69
|
},
|
|
57
70
|
has(key) { return active.has(key) || pending.has(key) },
|
|
58
71
|
get size() { return pending.size + active.size },
|
package/src/events.mjs
CHANGED
|
@@ -23,7 +23,15 @@ export function taskFromEvent(payload) {
|
|
|
23
23
|
|
|
24
24
|
export function taskAgentId(task) {
|
|
25
25
|
if (!task || typeof task !== 'object') return null
|
|
26
|
-
return task.agent_id
|
|
26
|
+
return task.agent_id ?? task.agentId ?? task.agent?.id ?? task.assigned_agent?.id ?? task.assignedAgent?.id ?? null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function taskBelongsToAgent(task, { id, identifier } = {}) {
|
|
30
|
+
if (!task || typeof task !== 'object') return false
|
|
31
|
+
const assignedId = taskAgentId(task)
|
|
32
|
+
if (assignedId != null && id != null) return String(assignedId) === String(id)
|
|
33
|
+
const assignedIdentifier = task.agent_identifier ?? task.agentIdentifier ?? task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? task.assignedAgent?.identifier ?? task.assignedAgent?.slug
|
|
34
|
+
return !!identifier && assignedIdentifier != null && String(assignedIdentifier) === String(identifier)
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
// Backend task ids are database plumbing. Teammates identify tickets by the
|
|
@@ -87,9 +95,9 @@ export function agentAddedByName(agent) {
|
|
|
87
95
|
return personDisplayName(addedBy)
|
|
88
96
|
}
|
|
89
97
|
|
|
90
|
-
export function buildTaskCompletionReport(task, { projectId, fallbackText = '', recipientName = '' } = {}) {
|
|
98
|
+
export function buildTaskCompletionReport(task, { projectId, fallbackText = '', recipientName = '', completedTypeIds = new Set(), reviewTypeIds = new Set() } = {}) {
|
|
91
99
|
if (!task || typeof task !== 'object' || task.id == null) return null
|
|
92
|
-
if (!taskIsCompleted(task) && !taskIsAwaitingReview(task)) return null
|
|
100
|
+
if (!taskIsCompleted(task, completedTypeIds) && !taskIsAwaitingReview(task, reviewTypeIds)) return null
|
|
93
101
|
|
|
94
102
|
const evidence = [task.description, fallbackText].filter(Boolean).join('\n')
|
|
95
103
|
const prUrl = /https:\/\/github\.com\/[^\s)\]}>]+\/pull\/\d+/i.exec(evidence)?.[0]?.replace(/[.,;:]+$/, '') || ''
|
|
@@ -97,7 +105,7 @@ export function buildTaskCompletionReport(task, { projectId, fallbackText = '',
|
|
|
97
105
|
|
|
98
106
|
const requester = String(recipientName || '').trim()
|
|
99
107
|
const title = String(task.title || 'Untitled task').replace(/\s+/g, ' ').trim().slice(0, 180)
|
|
100
|
-
const status = String(task.type?.name || task.task_type?.name || task.status || task.state || 'review').replace(/\s+/g, ' ').trim()
|
|
108
|
+
const status = String(task.type?.name || task.task_type?.name || task.status || task.state || (taskIsCompleted(task, completedTypeIds) ? 'completed' : 'review')).replace(/\s+/g, ' ').trim()
|
|
101
109
|
const verification = /\bVerification:\s*([^\n]{1,180})/i.exec(evidence)?.[1]?.replace(/\s+/g, ' ').trim().replace(/[.]+$/, '') || ''
|
|
102
110
|
const mention = requester ? `@${requester} ` : ''
|
|
103
111
|
const slug = ticketDisplaySlug(task)
|
|
@@ -137,9 +145,9 @@ export function opencodeEventEvidence(event) {
|
|
|
137
145
|
const command = String(input.command ?? input.cmd ?? '')
|
|
138
146
|
|
|
139
147
|
const lowerTool = tool.toLowerCase()
|
|
140
|
-
const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(tool)
|
|
148
|
+
const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:-watcher|_watcher)?(?:__|[_.:/-])(.+)$/i.exec(tool)
|
|
141
149
|
const bareTool = lowerTool.replace(/[-.]/g, '_')
|
|
142
|
-
const knownMcp = /^(?:get_ticket|list_tasks|list_task_types|update_ticket|post_message|comment_ticket|react_message|list_projects|list_agents|list_activity|list_codebases|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
|
|
150
|
+
const knownMcp = /^(?:get_ticket|list_tasks|list_task_types|update_ticket|post_message|create_task_comment|comment_ticket|react_message|list_projects|list_agents|list_activity|list_codebases|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
|
|
143
151
|
const mcpTool = (prefixed?.[1] ? prefixed[1].replace(/[-.]/g, '_') : (knownMcp.test(bareTool) ? bareTool : '')).toLowerCase()
|
|
144
152
|
const codebaseMutation = /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool)
|
|
145
153
|
const mutationTool = /^(?:edit|write|patch|apply_patch|multiedit|multi_edit)$/i.test(tool)
|
|
@@ -156,7 +164,7 @@ export function opencodeEventEvidence(event) {
|
|
|
156
164
|
didRepoMutation: completed && (mutationTool || codebaseMutation || (bashTool && commandMutation)),
|
|
157
165
|
didMcpTaskRead: completed && /^(?:get_ticket|list_tasks)$/.test(mcpTool),
|
|
158
166
|
didMcpTaskUpdate: completed && mcpTool === 'update_ticket',
|
|
159
|
-
didMessage: completed && /^(?:post_message|comment_ticket)$/.test(mcpTool),
|
|
167
|
+
didMessage: completed && /^(?:post_message|create_task_comment|comment_ticket)$/.test(mcpTool),
|
|
160
168
|
didChannelMessage: completed && mcpTool === 'post_message',
|
|
161
169
|
}
|
|
162
170
|
}
|
|
@@ -186,7 +194,7 @@ export function codexEventEvidence(event) {
|
|
|
186
194
|
if (itemType !== 'mcp_tool_call') return {}
|
|
187
195
|
|
|
188
196
|
const rawTool = String(item.tool ?? item.name ?? item.method ?? 'unknown')
|
|
189
|
-
const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(rawTool)
|
|
197
|
+
const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:-watcher|_watcher)?(?:__|[_.:/-])(.+)$/i.exec(rawTool)
|
|
190
198
|
const mcpTool = String(prefixed?.[1] ?? rawTool).replace(/[-.]/g, '_')
|
|
191
199
|
const succeeded = completed && !failed
|
|
192
200
|
const resultText = item.result?.content?.find?.((part) => part?.type === 'text')?.text
|
|
@@ -201,7 +209,7 @@ export function codexEventEvidence(event) {
|
|
|
201
209
|
didRepoMutation: succeeded && /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool),
|
|
202
210
|
didMcpTaskRead: succeeded && /^(?:get_ticket|list_tasks)$/.test(mcpTool),
|
|
203
211
|
didMcpTaskUpdate: succeeded && mcpTool === 'update_ticket',
|
|
204
|
-
didMessage: succeeded && /^(?:post_message|comment_ticket)$/.test(mcpTool),
|
|
212
|
+
didMessage: succeeded && /^(?:post_message|create_task_comment|comment_ticket)$/.test(mcpTool),
|
|
205
213
|
didChannelMessage: succeeded && mcpTool === 'post_message',
|
|
206
214
|
}
|
|
207
215
|
}
|
|
@@ -352,7 +360,7 @@ export function shouldSuppressCodexDiagnostic(value) {
|
|
|
352
360
|
export function mentionDedupeKeys(message, channelId) {
|
|
353
361
|
const m = message && typeof message === 'object' ? message : {}
|
|
354
362
|
const id = m.message_id ?? m.messageId ?? m.id
|
|
355
|
-
const parent = m
|
|
363
|
+
const parent = messageParentId(m) ?? ''
|
|
356
364
|
const text = String(m.content ?? m.body ?? m.text ?? m.message ?? '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
|
357
365
|
return {
|
|
358
366
|
idKey: id != null ? `id:${id}` : '',
|
|
@@ -360,10 +368,46 @@ export function mentionDedupeKeys(message, channelId) {
|
|
|
360
368
|
}
|
|
361
369
|
}
|
|
362
370
|
|
|
371
|
+
// Keep transport aliases consistent at intake, ownership verification and
|
|
372
|
+
// delivery deduplication. Unknown objects are not usable thread identities.
|
|
373
|
+
export function messageParentId(message) {
|
|
374
|
+
if (!message || typeof message !== 'object') return null
|
|
375
|
+
for (const key of ['parent_id', 'parentId', 'thread_id', 'threadId', 'reply_to', 'replyTo']) {
|
|
376
|
+
let value = message[key]
|
|
377
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) value = value.message_id ?? value.messageId ?? value.id
|
|
378
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value
|
|
379
|
+
if (typeof value === 'string' && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value) > 0) return value
|
|
380
|
+
}
|
|
381
|
+
return null
|
|
382
|
+
}
|
|
383
|
+
|
|
363
384
|
export function normalizeRenderedMessageText(value) {
|
|
364
385
|
return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase()
|
|
365
386
|
}
|
|
366
387
|
|
|
388
|
+
export function messageSenderIsSelf(row, identity = {}) {
|
|
389
|
+
if (!row || typeof row !== 'object') return false
|
|
390
|
+
const sender = [row.sender, row.user, row.author, row.member].find((item) => item && typeof item === 'object') || {}
|
|
391
|
+
const expanded = [row.senderAgent, row.sender_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
|
|
392
|
+
const senderKind = String(sender.type ?? sender.kind ?? sender.sender_type ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
|
|
393
|
+
const senderLooksLikeAgent = agentSenderKind(senderKind) || (!humanSenderKind(senderKind) && (sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)))
|
|
394
|
+
const agent = expanded || (senderLooksLikeAgent ? sender : {})
|
|
395
|
+
const agentId = agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId
|
|
396
|
+
const identifier = String(agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier ?? '')
|
|
397
|
+
const name = String(agent.name ?? agent.display_name ?? (senderLooksLikeAgent ? sender.name : '') ?? '')
|
|
398
|
+
const aliases = new Set([identity.identifier, identity.slug, identity.name].map((item) => String(item || '').toLowerCase()).filter(Boolean))
|
|
399
|
+
const stableAliases = new Set([identity.identifier, identity.slug].map((item) => String(item || '').toLowerCase()).filter(Boolean))
|
|
400
|
+
const hasId = (id) => id != null && String(id).trim() !== ''
|
|
401
|
+
// Stable identifiers are authoritative. Display names may be recycled or
|
|
402
|
+
// shared with a teammate and cannot override a conflicting agent id.
|
|
403
|
+
const isSelf = hasId(identity.id) && hasId(agentId)
|
|
404
|
+
? String(agentId) === String(identity.id)
|
|
405
|
+
: identifier && stableAliases.size
|
|
406
|
+
? stableAliases.has(identifier.toLowerCase())
|
|
407
|
+
: aliases.has(identifier.toLowerCase()) || (!!expanded && !identifier && aliases.has(name.toLowerCase()))
|
|
408
|
+
return !!isSelf
|
|
409
|
+
}
|
|
410
|
+
|
|
367
411
|
// Extract only messages visibly authored by this agent from the backend's live
|
|
368
412
|
// thread response. REST and WebSocket payloads use several sender shapes, so this
|
|
369
413
|
// mirrors the frontend normalizer instead of trusting one field name.
|
|
@@ -376,25 +420,15 @@ export function renderedAgentMessages(value, identity = {}) {
|
|
|
376
420
|
if (typeof node !== 'object') return
|
|
377
421
|
const row = node
|
|
378
422
|
const content = row.content ?? row.body ?? row.text ?? (typeof row.message === 'string' ? row.message : undefined)
|
|
379
|
-
const rowId = row.
|
|
423
|
+
const rowId = row.message_id ?? row.messageId ?? row.id
|
|
380
424
|
if (content != null && rowId != null) {
|
|
381
|
-
|
|
382
|
-
const expanded = [row.senderAgent, row.sender_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
|
|
383
|
-
const senderKind = String(sender.type ?? sender.kind ?? sender.sender_type ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
|
|
384
|
-
const senderLooksLikeAgent = /agent|bot/.test(senderKind) || sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)
|
|
385
|
-
const agent = expanded || (senderLooksLikeAgent ? sender : {})
|
|
386
|
-
const agentId = Number(agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId ?? NaN)
|
|
387
|
-
const identifier = String(agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier ?? '')
|
|
388
|
-
const name = String(agent.name ?? agent.display_name ?? (senderLooksLikeAgent ? sender.name : '') ?? '')
|
|
389
|
-
const aliases = new Set([identity.identifier, identity.slug, identity.name].map((item) => String(item || '').toLowerCase()).filter(Boolean))
|
|
390
|
-
const isSelf = (Number.isFinite(Number(identity.id)) && agentId === Number(identity.id)) || aliases.has(identifier.toLowerCase()) || (!!expanded && aliases.has(name.toLowerCase()))
|
|
391
|
-
if (isSelf) {
|
|
425
|
+
if (messageSenderIsSelf(row, identity)) {
|
|
392
426
|
const key = String(rowId)
|
|
393
427
|
if (!seen.has(key)) {
|
|
394
428
|
seen.add(key)
|
|
395
429
|
rows.push({
|
|
396
430
|
id: rowId,
|
|
397
|
-
parentId: row
|
|
431
|
+
parentId: messageParentId(row),
|
|
398
432
|
content: String(content),
|
|
399
433
|
})
|
|
400
434
|
}
|
|
@@ -425,14 +459,14 @@ function conversationMentions(value, selfAliases) {
|
|
|
425
459
|
for (const alias of aliases) {
|
|
426
460
|
// A sentence-ending period is punctuation, while a period followed by a
|
|
427
461
|
// handle character is still part of an alias such as `alex.morgan`.
|
|
428
|
-
const pattern = new RegExp(
|
|
462
|
+
const pattern = new RegExp(`(?<![a-z0-9_@])@${escapeRegex(alias)}(?![a-z0-9_-]|\\.(?=[a-z0-9]))`, 'gi')
|
|
429
463
|
for (const match of text.matchAll(pattern)) {
|
|
430
464
|
const index = match.index ?? 0
|
|
431
465
|
const end = index + match[0].length
|
|
432
466
|
if (!self.some((item) => index >= item.index && end <= item.end)) self.push({ name: alias, index, end })
|
|
433
467
|
}
|
|
434
468
|
}
|
|
435
|
-
const other = [...text.matchAll(
|
|
469
|
+
const other = [...text.matchAll(/(?<![a-z0-9_@])@([a-z0-9](?:[a-z0-9_.-]*[a-z0-9_-])?)(?=$|[^a-z0-9_.-]|\.(?:\s|$))/gi)]
|
|
436
470
|
.map((match) => ({ name: cleanAlias(match[1]), index: match.index ?? 0, end: (match.index ?? 0) + match[0].length }))
|
|
437
471
|
.filter((mention) => !self.some((item) => mention.index >= item.index && mention.end <= item.end))
|
|
438
472
|
return { self, other, all: [...self, ...other].sort((a, b) => a.index - b.index) }
|
|
@@ -441,11 +475,15 @@ function conversationMentions(value, selfAliases) {
|
|
|
441
475
|
export function messageSenderIsAgent(message) {
|
|
442
476
|
const m = message && typeof message === 'object' ? message : {}
|
|
443
477
|
if (m.senderAgent || m.sender_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null) return true
|
|
444
|
-
const sender = [m.sender, m.user, m.author].find((item) => item && typeof item === 'object') || {}
|
|
478
|
+
const sender = [m.sender, m.user, m.author, m.member].find((item) => item && typeof item === 'object') || {}
|
|
479
|
+
if (sender.agent && typeof sender.agent === 'object') return true
|
|
445
480
|
const kind = String(sender.type ?? sender.kind ?? sender.sender_type ?? m.sender_type ?? m.author_type ?? '').toLowerCase()
|
|
446
|
-
return
|
|
481
|
+
return agentSenderKind(kind) || (!humanSenderKind(kind) && (sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)))
|
|
447
482
|
}
|
|
448
483
|
|
|
484
|
+
const agentSenderKind = (kind) => /(?:^|[_ -])(?:agent|bot)(?:$|[_ -])/.test(kind)
|
|
485
|
+
const humanSenderKind = (kind) => /^(?:human|user|member|person)$/.test(kind)
|
|
486
|
+
|
|
449
487
|
// WebSocket deployments may fan every reply in a watched thread as
|
|
450
488
|
// `agent:mention`, even when the new message names somebody else. Treat the event
|
|
451
489
|
// name as a wake-up hint only: recipient ownership is decided from the actual
|
|
@@ -456,6 +494,8 @@ export function classifyConversationTarget(message, selfAliases, { threadOwned =
|
|
|
456
494
|
const { self: selfMentions, other: otherMentions, all: mentions } = conversationMentions(text, selfAliases)
|
|
457
495
|
const explicitSelf = selfMentions.length > 0
|
|
458
496
|
|
|
497
|
+
if (!text) return { action: 'ignore', reason: 'empty-message', text, explicitSelf, otherMentions: [] }
|
|
498
|
+
|
|
459
499
|
if (messageSenderIsAgent(m) && !explicitSelf) {
|
|
460
500
|
return { action: 'ignore', reason: 'other-agent-chatter', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
461
501
|
}
|
|
@@ -463,8 +503,12 @@ export function classifyConversationTarget(message, selfAliases, { threadOwned =
|
|
|
463
503
|
return { action: 'ignore', reason: 'explicit-other-recipient', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
464
504
|
}
|
|
465
505
|
|
|
466
|
-
const standDown = /\b(?:do\s+not|don't|dont)\s+(?:pick|take|handle|work\s+on|start|continue)\b|\b(?:stand\s+down|stop\s+(?:working|handling|on\s+this)|leave\s+(?:this|it)\s+(?:alone|to\s+\S+)|cancel\s+(?:this|that|it)|not\s+(?:for|yours?))\b/i.test(text)
|
|
467
|
-
|
|
506
|
+
const standDown = /\b(?:do\s+not|don't|dont)\s+(?:pick|take|handle|work\s+on|start|continue|fix|implement|code)\b|\b(?:stand\s+down|stop\s+(?:working|handling|on\s+this)|leave\s+(?:this|it)\s+(?:alone|to\s+\S+)|cancel\s+(?:this|that|it)|not\s+(?:for|yours?))\b/i.test(text)
|
|
507
|
+
const readOnlyRequest = /\b(?:(?:just|only)\s+(?:list|check|tell|show)|status\s+only)\b/i.test(text)
|
|
508
|
+
const stopActiveWork = /\b(?:stand\s+down|stop\s+(?:working|handling|on\s+this)|cancel\s+(?:this|that|it)|leave\s+(?:this|it)\s+(?:alone|to\s+\S+)|(?:do\s+not|don't|dont)\s+(?:continue|handle|work\s+on))\b/i.test(text)
|
|
509
|
+
// “Just list my tickets, do not start” requests an answer with work pickup
|
|
510
|
+
// disabled. An instruction to stop active work still takes precedence.
|
|
511
|
+
if (standDown && (!readOnlyRequest || stopActiveWork) && (explicitSelf || (threadOwned && !mentions.length))) {
|
|
468
512
|
return { action: 'stand_down', reason: 'explicit-stand-down', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
469
513
|
}
|
|
470
514
|
|
|
@@ -489,15 +533,22 @@ export function classifyConversationTarget(message, selfAliases, { threadOwned =
|
|
|
489
533
|
// own describe plenty of product conversations and must not start a coding lane.
|
|
490
534
|
export function conversationNeedsCode(value, { rootContent = '' } = {}) {
|
|
491
535
|
const text = String(value || '')
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
536
|
+
// Negation scopes the following instruction, not every independent request
|
|
537
|
+
// in the message. “Fix the component; don't deploy” still requests a fix.
|
|
538
|
+
const clauses = text.split(/[;!?]|\b(?:but|however|instead)\b/i)
|
|
539
|
+
return clauses.some((clause) => {
|
|
540
|
+
const positive = clause.split(/\b(?:do\s+not|don't|dont|stand\s+down|stop\s+(?:working|handling|on\s+this)|cancel\s+(?:this|that|it|the\s+(?:task|ticket)))\b/i)[0]
|
|
541
|
+
const action = /\b(?:implement|fix|debug|repair|rebuild|rerun|refactor|change|update|add|remove|write|edit|test|build|deploy|release|migrate|wire|integrate)\b/i.test(positive)
|
|
542
|
+
const target = /\b(?:code|codebase|repository|repo|github|branch|commit|pull\s+request|pr|endpoint|route|component|page|screen|ui|function|class|database|migration|schema|package|typescript|javascript|python|swift|rust|golang|css|html|source\s+file|tests?)\b/i.test(positive)
|
|
543
|
+
const contextualRepair = /\b(?:fix|debug|repair|rebuild|rerun|run\s+(?:the\s+)?(?:build|tests?))\b/i.test(positive) && /\b(?:github\.com\/\S+\/pull\/\d+|pull\s+request|repository|repo|branch|commit)\b/i.test(rootContent)
|
|
544
|
+
return (action && target) || contextualRepair
|
|
545
|
+
})
|
|
497
546
|
}
|
|
498
547
|
|
|
499
548
|
export function conversationAsksPendingTickets(value) {
|
|
500
|
-
|
|
549
|
+
// Project prefixes such as OPEN-77 are identifiers, not the word “open”.
|
|
550
|
+
// A question about a finished ticket must not become a backlog pickup.
|
|
551
|
+
const text = String(value || '').replace(/\b[a-z][a-z0-9]*-\d+\b/gi, '').replace(/\s+/g, ' ').trim()
|
|
501
552
|
if (!/\b(?:ticket|tickets|task|tasks)\b/i.test(text)) return false
|
|
502
553
|
const pending = /\b(?:pending|open|assigned|backlog|outstanding|in[ -]?progress|working on)\b/i.test(text)
|
|
503
554
|
const question = /\?|\b(?:any|what|which|how many|do you|have you|are there|show|list|check|verify|asked|whether)\b/i.test(text)
|
|
@@ -509,7 +560,7 @@ export function conversationAsksPendingTickets(value) {
|
|
|
509
560
|
// precise, scoped answer (or say which live fact it could not read). Failed team
|
|
510
561
|
// mutations remain fail-closed so prose can never masquerade as a completed act.
|
|
511
562
|
export function blockingReplyMcpErrors(errors) {
|
|
512
|
-
const mutation = /^(?:update_ticket|post_message|comment_ticket|react_message|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
|
|
563
|
+
const mutation = /^(?:update_ticket|post_message|create_task_comment|comment_ticket|react_message|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
|
|
513
564
|
return [...new Set((Array.isArray(errors) ? errors : []).map((name) => String(name || '').replace(/^.*(?:__|[.:/])/, '').replace(/[-.]/g, '_').toLowerCase()).filter((name) => mutation.test(name)))]
|
|
514
565
|
}
|
|
515
566
|
|
|
@@ -539,7 +590,7 @@ export function requestTargetsLaterAgent(text, selfAliases) {
|
|
|
539
590
|
|
|
540
591
|
const bridge = value.slice(self.end, later.index).toLowerCase()
|
|
541
592
|
const suffix = value.slice(later.end).trim().toLowerCase()
|
|
542
|
-
const shared =
|
|
593
|
+
const shared = /^(?:\s*[,;:]?\s*)(?:and|with|alongside|&)\s*$/.test(bridge)
|
|
543
594
|
if (shared) return false
|
|
544
|
-
return
|
|
595
|
+
return /^(?:[,.:;-]\s*)?(?:please\s+)?(?:can|could|would|will|do|write|create|make|send|fix|check|review|prepare|give|tell|help|handle|take|move|update|implement)\b/i.test(suffix)
|
|
545
596
|
}
|