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
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { assignmentRequest, assignmentStatusOnly, routeAssignments } from '../src/assignment-routing.mjs'
|
|
3
|
+
import { dedicatedChannel, repliesAfterSource } from '../src/channel-routing.mjs'
|
|
4
|
+
import { buildTaskCompletionReport, classifyConversationTarget, conversationAsksPendingTickets, conversationNeedsCode, mentionDedupeKeys, messageParentId, renderedAgentMessages, taskAgentId, taskBelongsToAgent, taskFromEvent } from '../src/events.mjs'
|
|
5
|
+
import { createThreadContextResolver, ownThreadRoot } from '../src/thread-context.mjs'
|
|
6
|
+
|
|
7
|
+
// Every axis below changes a supported transport or a decision input. Expected
|
|
8
|
+
// outcomes describe workspace policy independently of production conditionals.
|
|
9
|
+
export const scenarios = []
|
|
10
|
+
const add = (id, category, description, run) => scenarios.push({ id: `routing-${id}`, category, description, run })
|
|
11
|
+
const identity = { id: 7, identifier: 'alex', name: 'Alex Morgan' }
|
|
12
|
+
const aliases = ['alex', 'alex morgan']
|
|
13
|
+
const senders = [
|
|
14
|
+
['human-email', { sender: { id: 7, email: 'alex@example.test' } }, false],
|
|
15
|
+
['human-explicit-kind', { sender: { type: 'human', id: 7, identifier: 'alex' } }, false],
|
|
16
|
+
['human-implicit', {}, false],
|
|
17
|
+
['agent-expanded-camel', { senderAgent: { id: 9 } }, true],
|
|
18
|
+
['agent-expanded-snake', { sender_agent: { id: 9 } }, true],
|
|
19
|
+
['agent-flat-id', { agent_id: 9 }, true],
|
|
20
|
+
['agent-sender-kind', { sender: { type: 'agent', identifier: 'atlas' } }, true],
|
|
21
|
+
['agent-member-kind', { member: { type: 'bot', id: 9 } }, true],
|
|
22
|
+
['agent-nested-principal', { sender: { agent: { id: 9, identifier: 'atlas' } } }, true],
|
|
23
|
+
]
|
|
24
|
+
const recipientCases = [
|
|
25
|
+
['direct-request', '@Alex please fix the component.', 'handle'],
|
|
26
|
+
['full-name-request', '@Alex Morgan please review the PR.', 'handle'],
|
|
27
|
+
['case-insensitive', '@ALEX please check this.', 'handle'],
|
|
28
|
+
['mention-punctuation', '(@Alex), can you check?', 'handle'],
|
|
29
|
+
['unmentioned-follow-up', 'The build failed, please fix it.', 'follow-up'],
|
|
30
|
+
['unmentioned-thanks', 'Thanks for the update.', 'follow-up'],
|
|
31
|
+
['different-recipient', '@Atlas please fix this.', 'ignore'],
|
|
32
|
+
['different-full-name', '@Atlas Smith please fix this.', 'ignore'],
|
|
33
|
+
['explicit-stop', '@Alex stop working on this.', 'stand_down'],
|
|
34
|
+
['explicit-do-not-start', "@Alex don't start this ticket.", 'stand_down'],
|
|
35
|
+
['explicit-do-not-fix', '@Alex do not fix this.', 'stand_down'],
|
|
36
|
+
['limited-no-deploy', "@Alex fix the component; don't deploy it.", 'handle'],
|
|
37
|
+
['unmentioned-stop', 'Stop working on this.', 'stop-follow-up'],
|
|
38
|
+
['later-agent-handoff', '@Alex thanks. @Atlas please fix the build.', 'ignore'],
|
|
39
|
+
['shared-request', '@Alex and @Atlas please review this.', 'handle'],
|
|
40
|
+
['explicit-shared-request', '@Alex and @Atlas can you both fix this?', 'handle'],
|
|
41
|
+
['delegation-through-self', '@Alex ask @Atlas to review this.', 'handle'],
|
|
42
|
+
['mention-in-email', 'Please send the update to contact@alex.', 'follow-up'],
|
|
43
|
+
['email-to-other-person', 'Please use daniel@atlas.example for contact.', 'follow-up'],
|
|
44
|
+
['longer-handle', '@Alexandra please review.', 'ignore'],
|
|
45
|
+
['dotted-different-handle', '@Alex.morgan please review.', 'ignore'],
|
|
46
|
+
['empty-event', ' ', 'ignore'],
|
|
47
|
+
['reference-other-agent', "@Alex compare @Atlas's branch with yours?", 'handle'],
|
|
48
|
+
]
|
|
49
|
+
for (const [caseId, content, policy] of recipientCases) {
|
|
50
|
+
for (const [senderId, sender, agent] of senders) {
|
|
51
|
+
for (const owned of [false, true]) {
|
|
52
|
+
const expected = policy === 'follow-up' ? (!agent && owned ? 'handle' : 'ignore')
|
|
53
|
+
: policy === 'stop-follow-up' ? (!agent && owned ? 'stand_down' : 'ignore') : policy
|
|
54
|
+
add(`recipient-${caseId}-${senderId}-${owned ? 'owned' : 'unowned'}`, 'recipient', `${caseId}: ${senderId} in ${owned ? 'an owned' : 'an unowned'} thread must ${expected}.`, () => {
|
|
55
|
+
assert.equal(classifyConversationTarget({ id: 101, parent_id: 100, content, ...sender }, aliases, { threadOwned: owned }).action, expected)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const [caseId, content, policy] of [
|
|
61
|
+
['list-with-no-start', '@Alex just list my pending tickets, do not start work.', 'handle'],
|
|
62
|
+
['status-with-no-fix', '@Alex status only: show the ticket, do not fix it.', 'handle'],
|
|
63
|
+
['stop-active-and-list', '@Alex stop working on this and just list my pending tickets.', 'stand_down'],
|
|
64
|
+
['do-not-continue-and-list', '@Alex do not continue; just list my pending tickets.', 'stand_down'],
|
|
65
|
+
['cancel-and-list', '@Alex cancel this; just list my pending tickets.', 'stand_down'],
|
|
66
|
+
['untagged-list-with-no-start', 'Just list my pending tickets, do not start work.', 'follow-up'],
|
|
67
|
+
]) for (const owned of [false, true]) {
|
|
68
|
+
add(`readonly-recipient-${caseId}-${owned ? 'owned' : 'unowned'}`, 'recipient', `${caseId} answers explicit inventory requests while preserving active-work cancellation precedence.`, () => {
|
|
69
|
+
const expected = policy === 'follow-up' ? (owned ? 'handle' : 'ignore') : policy
|
|
70
|
+
assert.equal(classifyConversationTarget({ content }, aliases, { threadOwned: owned }).action, expected)
|
|
71
|
+
assert.equal(assignmentStatusOnly(content), true)
|
|
72
|
+
assert.equal(assignmentRequest(content), null)
|
|
73
|
+
assert.equal(conversationNeedsCode(content), false)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const rootContent = 'Finished OPEN-77. PR: https://github.com/org/repo/pull/62.'
|
|
78
|
+
const root = { id: 100, content: rootContent, sender_agent: { id: 7, identifier: 'alex', name: 'Alex Morgan' } }
|
|
79
|
+
const threadEnvelopes = [
|
|
80
|
+
['direct', (row) => row], ['messages', (row) => ({ messages: [row] })],
|
|
81
|
+
['message-and-replies', (row) => ({ message: row, replies: [] })],
|
|
82
|
+
['data-message', (row) => ({ data: { message: row } })],
|
|
83
|
+
['result-items', (row) => ({ result: { items: [row] } })],
|
|
84
|
+
['thread-root', (row) => ({ thread: { root: row } })],
|
|
85
|
+
['array-root', (row) => [row]], ['data-messages', (row) => ({ data: { messages: [row] } })],
|
|
86
|
+
]
|
|
87
|
+
const authors = [
|
|
88
|
+
['exact-identity', { sender_agent: { id: 7, identifier: 'alex' } }, true],
|
|
89
|
+
['string-id', { senderAgent: { id: '7', identifier: 'alex' } }, true],
|
|
90
|
+
['identifier-only', { agent: { identifier: 'alex' } }, true],
|
|
91
|
+
['flat-id', { agent_id: 7 }, true],
|
|
92
|
+
['member-envelope', { member: { type: 'agent', id: 7, identifier: 'alex' } }, true],
|
|
93
|
+
['conflicting-id-same-identifier', { agent: { id: 8, identifier: 'alex', name: 'Alex Morgan' } }, false],
|
|
94
|
+
['conflicting-identifier-same-name', { agent: { identifier: 'atlas', name: 'Alex Morgan' } }, false],
|
|
95
|
+
['human-same-id', { sender: { id: 7, name: 'Alex Morgan', email: 'alex@example.test' } }, false],
|
|
96
|
+
['explicit-human-identifier', { sender: { type: 'user', id: 7, identifier: 'alex' } }, false],
|
|
97
|
+
['other-agent', { sender_agent: { id: 8, identifier: 'atlas' } }, false],
|
|
98
|
+
]
|
|
99
|
+
for (const [envelopeId, wrap] of threadEnvelopes) for (const [authorId, sender, self] of authors) {
|
|
100
|
+
add(`root-${envelopeId}-${authorId}`, 'thread-ownership', `${envelopeId} root authored by ${authorId} ${self ? 'establishes' : 'does not establish'} ownership.`, () => {
|
|
101
|
+
const value = wrap({ id: 100, content: rootContent, ...sender })
|
|
102
|
+
assert.equal(ownThreadRoot(value, 100, identity)?.content ?? null, self ? rootContent : null)
|
|
103
|
+
assert.equal(renderedAgentMessages(value, identity).length, self ? 1 : 0)
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
for (const field of ['parent_id', 'parentId', 'thread_id', 'threadId', 'reply_to', 'replyTo']) {
|
|
107
|
+
add(`non-root-${field}`, 'thread-ownership', `An agent reply carrying ${field} cannot masquerade as a root.`, () => {
|
|
108
|
+
assert.equal(ownThreadRoot({ ...root, [field]: 90 }, 100, identity), null)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
for (const field of ['parent_id', 'parentId', 'thread_id', 'threadId', 'reply_to', 'replyTo']) {
|
|
112
|
+
for (const [shape, value] of [['scalar', 90], ['association-id', { id: 90 }], ['association-message-id', { message_id: 90 }]]) {
|
|
113
|
+
add(`parent-alias-${field}-${shape}`, 'thread-ownership', `${field}/${shape} retains the same thread identity in ownership and replay deduplication.`, () => {
|
|
114
|
+
const row = { ...root, [field]: value }
|
|
115
|
+
assert.equal(messageParentId(row), 90)
|
|
116
|
+
assert.equal(ownThreadRoot(row, 100, identity), null)
|
|
117
|
+
assert.equal(mentionDedupeKeys(row, 2).signatureKey, `sig:2|90|${rootContent}`)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const [label, value] of [['unknown-object', { unknown: 90 }], ['array', [90]], ['boolean', true], ['empty', ''], ['not-finite', NaN], ['negative-string', '-1'], ['zero-string', '0'], ['padded-zero', '00'], ['non-numeric-string', 'NaN']]) {
|
|
122
|
+
add(`parent-invalid-${label}`, 'thread-ownership', `${label} parent transport cannot become a malformed API thread ID.`, () => {
|
|
123
|
+
assert.equal(messageParentId({ parent_id: value }), null)
|
|
124
|
+
assert.equal(messageParentId({ parent_id: value, thread_id: 90 }), 90)
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
for (const field of ['message_id', 'messageId']) add(`root-canonical-message-id-${field}`, 'thread-ownership', `${field} wins over an unrelated transport envelope id.`, () => {
|
|
128
|
+
const row = { ...root, id: 'event-envelope', [field]: 100 }
|
|
129
|
+
assert.equal(ownThreadRoot(row, 100, identity)?.content, rootContent)
|
|
130
|
+
assert.equal(mentionDedupeKeys(row, 2).idKey, 'id:100')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const channelCases = [
|
|
134
|
+
['exact-name', [{ id: 1, name: 'alex' }], { aliases: ['Alex'] }, 1],
|
|
135
|
+
['prefixed-name', [{ id: 1, name: 'agent-alex' }], { aliases: ['Alex'] }, 1],
|
|
136
|
+
['suffixed-name', [{ id: 1, name: 'alex-agent' }], { aliases: ['Alex'] }, 1],
|
|
137
|
+
['full-name-normalized', [{ id: 1, name: '#Alex Morgan' }], { aliases: ['Alex Morgan'] }, 1],
|
|
138
|
+
['numeric-owner', [{ id: 1, name: 'private', agent_id: 7 }], identity, 1],
|
|
139
|
+
['camel-owner', [{ id: 1, name: 'private', agentId: 7 }], identity, 1],
|
|
140
|
+
['identifier-owner', [{ id: 1, name: 'private', agent_identifier: 'alex' }], identity, 1],
|
|
141
|
+
['camel-identifier-owner', [{ id: 1, name: 'private', agentIdentifier: 'alex' }], identity, 1],
|
|
142
|
+
['explicit-config', [{ id: 1, name: 'configured' }], { channelId: 1 }, 1],
|
|
143
|
+
['missing-config', [{ id: 1, name: 'alex' }], { ...identity, channelId: 2 }, null],
|
|
144
|
+
['invalid-config-boolean', [{ id: 1, name: 'alex' }], { ...identity, channelId: true }, null],
|
|
145
|
+
['invalid-config-zero', [{ id: 1, name: 'alex' }], { ...identity, channelId: 0 }, null],
|
|
146
|
+
['invalid-config-negative', [{ id: 1, name: 'alex' }], { ...identity, channelId: -1 }, null],
|
|
147
|
+
['ambiguous-name', [{ id: 1, name: 'alex' }, { id: 2, name: 'agent-alex' }], identity, null],
|
|
148
|
+
['ambiguous-owner', [{ id: 1, agent_id: 7 }, { id: 2, agent_id: 7 }], identity, null],
|
|
149
|
+
['other-owner-same-name', [{ id: 1, name: 'alex', agent_id: 8 }], identity, null],
|
|
150
|
+
['other-owner-same-identifier', [{ id: 1, name: 'alex', agent_id: 8, agent_identifier: 'alex' }], identity, null],
|
|
151
|
+
['other-identifier-same-name', [{ id: 1, name: 'alex', agent_identifier: 'atlas' }], identity, null],
|
|
152
|
+
['general-not-fallback', [{ id: 1, name: 'general' }], identity, null],
|
|
153
|
+
['team-not-fallback', [{ id: 1, name: 'team' }], identity, null],
|
|
154
|
+
['no-channels', [], identity, null],
|
|
155
|
+
['invalid-channel-list', null, identity, null],
|
|
156
|
+
['boolean-channel-id', [{ id: true, name: 'alex' }], identity, null],
|
|
157
|
+
['unsafe-channel-id', [{ id: Number.MAX_SAFE_INTEGER + 1, name: 'alex' }], identity, null],
|
|
158
|
+
]
|
|
159
|
+
for (const [id, channels, options, expected] of channelCases) add(`channel-${id}`, 'dedicated-channel', `Dedicated channel selection: ${id}.`, () => {
|
|
160
|
+
assert.equal(dedicatedChannel(channels, options)?.id ?? null, expected)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const codeIntents = [
|
|
164
|
+
['direct-fix', 'Please fix the component.', false, true],
|
|
165
|
+
['stop-button', 'Implement a stop button in the component.', false, true],
|
|
166
|
+
['cancel-button', 'Fix the cancel button in the component.', false, true],
|
|
167
|
+
['negative-fix', 'Do not fix the component.', false, false],
|
|
168
|
+
['fix-with-no-deploy', "Fix the component; don't deploy it.", false, true],
|
|
169
|
+
['fix-with-inline-no-deploy', "Fix the component and don't deploy it.", false, true],
|
|
170
|
+
['negative-with-alternative', 'Do not deploy, but fix the component.', false, true],
|
|
171
|
+
['build-contextual', 'The build failed so run build and fix the issue.', true, true],
|
|
172
|
+
['build-without-context', 'The build failed so run build and fix the issue.', false, false],
|
|
173
|
+
['rerun-tests-contextual', 'Run the tests again.', true, true],
|
|
174
|
+
['thanks-contextual', 'Thanks for fixing that.', true, false],
|
|
175
|
+
['status-contextual', 'How is it going?', true, false],
|
|
176
|
+
['standdown-contextual', 'Stop working on the repository.', true, false],
|
|
177
|
+
['feature-description', 'This feature is about API documentation.', false, false],
|
|
178
|
+
]
|
|
179
|
+
for (const [id, text, context, expected] of codeIntents) add(`code-intent-${id}`, 'request-intent', `Coding lane intent: ${id}.`, () => {
|
|
180
|
+
assert.equal(conversationNeedsCode(text, { rootContent: context ? rootContent : '' }), expected)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const actions = ['Start', 'Resume', 'Continue', 'Implement', 'Handle', 'Work through']
|
|
184
|
+
const assignmentTargets = [
|
|
185
|
+
['one-slug', 'open-77', ['OPEN-77']], ['two-slugs', 'open-77 and core-4', ['OPEN-77', 'CORE-4']],
|
|
186
|
+
['duplicate-slug', 'OPEN-77 and open-77', ['OPEN-77']], ['pending-tickets', 'my pending tickets', []],
|
|
187
|
+
['assigned-tasks', 'your assigned tasks', []], ['backlog', 'the backlog', []],
|
|
188
|
+
]
|
|
189
|
+
for (const action of actions) for (const [targetId, target, slugs] of assignmentTargets) {
|
|
190
|
+
add(`assignment-intent-${action.toLowerCase().replaceAll(' ', '-')}-${targetId}`, 'assignment-intent', `${action} ${target} resolves its explicitly requested scope.`, () => {
|
|
191
|
+
assert.deepEqual(assignmentRequest(`${action} ${target}`), { slugs })
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
for (const prefix of ['Do not start', "Don't implement", 'Just list', 'Only check']) for (const [targetId, target] of assignmentTargets) {
|
|
195
|
+
add(`assignment-readonly-${prefix.toLowerCase().replaceAll(' ', '-').replaceAll("'", '')}-${targetId}`, 'assignment-intent', `${prefix} ${target} must not dispatch coding.`, () => {
|
|
196
|
+
assert.equal(assignmentRequest(`${prefix} ${target}`), null)
|
|
197
|
+
assert.equal(assignmentStatusOnly(`${prefix} ${target}`), true)
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
for (const term of ['stop', 'cancel']) add(`assignment-feature-${term}-button`, 'assignment-intent', `A ${term} button feature name is not a work cancellation.`, () => {
|
|
201
|
+
assert.deepEqual(assignmentRequest(`Implement the ${term} button for OPEN-77`), { slugs: ['OPEN-77'] })
|
|
202
|
+
})
|
|
203
|
+
for (const [id, text] of [
|
|
204
|
+
['check-if', 'Can you check if you have pending tasks?'],
|
|
205
|
+
['list-assigned', 'List the tickets I assigned you.'],
|
|
206
|
+
['tell-assigned', 'Tell me which tasks I assigned you.'],
|
|
207
|
+
['whether-assigned', 'Verify whether you have any assigned tickets.'],
|
|
208
|
+
['pending-question', 'Do you have pending tickets?'],
|
|
209
|
+
]) add(`assignment-query-${id}`, 'assignment-intent', `A ${id} inventory question uses watcher discovery and permits automatic pickup of existing actionable assignments.`, () => {
|
|
210
|
+
assert.equal(assignmentRequest(text), null)
|
|
211
|
+
assert.equal(conversationAsksPendingTickets(text), true)
|
|
212
|
+
assert.equal(assignmentStatusOnly(text), false)
|
|
213
|
+
})
|
|
214
|
+
add('completed-ticket-status-does-not-reopen', 'assignment-intent', 'A status question about one completed ticket does not request new work or trigger pending inventory discovery.', async () => {
|
|
215
|
+
const text = 'What is the status of completed ticket OPEN-77?'
|
|
216
|
+
assert.equal(assignmentRequest(text), null)
|
|
217
|
+
assert.equal(conversationAsksPendingTickets(text), false)
|
|
218
|
+
let dispatched = 0
|
|
219
|
+
await routeAssignments({ request: { slugs: ['OPEN-77'] }, loadTickets: async () => [{ id: 77, projectId: 1, slug: 'OPEN-77', status: 'Done' }], handleTask: async () => { dispatched++ } })
|
|
220
|
+
assert.equal(dispatched, 0)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const ticketStates = [
|
|
224
|
+
['actionable', { status: 'In Progress' }, true],
|
|
225
|
+
['review', { awaitingReview: true, status: 'Testing' }, false],
|
|
226
|
+
['completed', { status: 'Done' }, false],
|
|
227
|
+
]
|
|
228
|
+
for (const first of ticketStates) for (const second of ticketStates) for (const third of ticketStates) {
|
|
229
|
+
for (const scope of ['all', 'one']) {
|
|
230
|
+
const states = [first, second, third]
|
|
231
|
+
add(`dispatch-${states.map(([name]) => name).join('-')}-${scope}`, 'assignment-dispatch', `${scope} scope across ${states.map(([name]) => name).join('/')} tickets dispatches only actionable work.`, async () => {
|
|
232
|
+
const tickets = states.map(([, state], i) => ({ id: 77 + i, projectId: i === 2 ? 2 : 1, slug: `OPEN-${77 + i}`, ...state }))
|
|
233
|
+
const calls = []
|
|
234
|
+
const count = await routeAssignments({ request: { slugs: scope === 'one' ? ['OPEN-77'] : [] }, loadTickets: async () => tickets, handleTask: async (event, payload) => calls.push([event, payload.task.id]) })
|
|
235
|
+
const expected = states.flatMap(([, , actionable], i) => actionable && (scope === 'all' || i === 0) ? [['task:assigned', 77 + i]] : [])
|
|
236
|
+
assert.deepEqual(calls, expected)
|
|
237
|
+
assert.equal(count, expected.length)
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const slugField of ['slug', 'ticket_slug', 'ticketSlug']) add(`dispatch-normalized-${slugField}`, 'assignment-dispatch', `Explicit slug matching accepts lowercase ${slugField} transport fields.`, async () => {
|
|
242
|
+
const calls = []
|
|
243
|
+
await routeAssignments({ request: { slugs: ['OPEN-77'] }, loadTickets: async () => [{ id: 77, project_id: 1, [slugField]: 'open-77' }], handleTask: async (_, payload) => calls.push(payload.task) })
|
|
244
|
+
assert.deepEqual(calls, [{ id: 77, project_id: 1 }])
|
|
245
|
+
})
|
|
246
|
+
for (const mode of ['before-first', 'after-first', 'after-second']) add(`dispatch-cancel-${mode}`, 'assignment-dispatch', `Cancellation ${mode} prevents subsequent ticket dispatch.`, async () => {
|
|
247
|
+
const stopAt = { 'before-first': 0, 'after-first': 1, 'after-second': 2 }[mode]
|
|
248
|
+
let calls = 0
|
|
249
|
+
const count = await routeAssignments({ request: { slugs: [] }, loadTickets: async () => [1, 2, 3].map((id) => ({ id, projectId: 1 })), isCancelled: () => calls >= stopAt, handleTask: async () => { calls++ } })
|
|
250
|
+
assert.equal(count, stopAt)
|
|
251
|
+
assert.equal(calls, stopAt)
|
|
252
|
+
})
|
|
253
|
+
add('dispatch-deduplicates-list', 'assignment-dispatch', 'Duplicate rows during pagination cannot enqueue the same project ticket twice.', async () => {
|
|
254
|
+
let calls = 0
|
|
255
|
+
const ticket = { id: 77, projectId: 1, slug: 'OPEN-77' }
|
|
256
|
+
assert.equal(await routeAssignments({ request: { slugs: [] }, loadTickets: async () => [ticket, { ...ticket }], handleTask: async () => { calls++ } }), 1)
|
|
257
|
+
assert.equal(calls, 1)
|
|
258
|
+
})
|
|
259
|
+
for (const issue of ['missing-slug', 'ambiguous-slug', 'invalid-list']) add(`dispatch-reject-${issue}`, 'assignment-dispatch', `${issue} fails before any work side effect.`, async () => {
|
|
260
|
+
let calls = 0
|
|
261
|
+
const ticket = { id: 77, projectId: 1, slug: 'OPEN-77' }
|
|
262
|
+
await assert.rejects(routeAssignments({ request: { slugs: ['OPEN-77'] }, loadTickets: async () => issue === 'invalid-list' ? {} : issue === 'missing-slug' ? [] : [ticket, { ...ticket, projectId: 2 }], handleTask: async () => { calls++ } }))
|
|
263
|
+
assert.equal(calls, 0)
|
|
264
|
+
})
|
|
265
|
+
add('dispatch-reject-partial-invalid-list', 'assignment-dispatch', 'An invalid later assignment fails validation before an earlier assignment is dispatched.', async () => {
|
|
266
|
+
let calls = 0
|
|
267
|
+
await assert.rejects(routeAssignments({ request: { slugs: [] }, loadTickets: async () => [{ id: 77, projectId: 1 }, { id: 78 }], handleTask: async () => { calls++ } }), /missing/)
|
|
268
|
+
assert.equal(calls, 0)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
const assignmentShapes = [
|
|
272
|
+
['snake', (id, identifier) => ({ agent_id: id, agent_identifier: identifier })],
|
|
273
|
+
['camel', (id, identifier) => ({ agentId: id, agentIdentifier: identifier })],
|
|
274
|
+
['expanded', (id, identifier) => ({ agent: { id, identifier } })],
|
|
275
|
+
['assigned-snake', (id, identifier) => ({ assigned_agent: { id, identifier } })],
|
|
276
|
+
['assigned-camel', (id, identifier) => ({ assignedAgent: { id, identifier } })],
|
|
277
|
+
]
|
|
278
|
+
const identities = [
|
|
279
|
+
['self-id', 7, 'alex', true], ['self-string-id', '7', 'alex', true],
|
|
280
|
+
['other-id-same-identifier', 8, 'alex', false], ['self-id-other-identifier', 7, 'atlas', true],
|
|
281
|
+
['identifier-fallback', null, 'alex', true], ['other-identifier', null, 'atlas', false],
|
|
282
|
+
['unassigned', null, null, false],
|
|
283
|
+
]
|
|
284
|
+
for (const [shape, make] of assignmentShapes) for (const [state, id, identifier, expected] of identities) {
|
|
285
|
+
add(`assignment-owner-${shape}-${state}`, 'assignment-ownership', `${shape} assignment with ${state} uses stable ID precedence.`, () => {
|
|
286
|
+
const task = { id: 77, ...make(id, identifier) }
|
|
287
|
+
assert.equal(taskAgentId(task), id)
|
|
288
|
+
assert.equal(taskBelongsToAgent(task, identity), expected)
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
const taskEnvelopes = [
|
|
292
|
+
['direct', (task) => task], ['task', (task) => ({ task })], ['data-task', (task) => ({ data: { task } })],
|
|
293
|
+
['payload-task', (task) => ({ payload: { task } })], ['data', (task) => ({ data: task })], ['payload', (task) => ({ payload: task })],
|
|
294
|
+
]
|
|
295
|
+
for (const [envelope, wrap] of taskEnvelopes) for (const [shape, make] of assignmentShapes) {
|
|
296
|
+
add(`task-transport-${envelope}-${shape}`, 'task-transport', `${envelope}/${shape} event retains assignment identity through normalization.`, () => {
|
|
297
|
+
const task = { id: 77, ...make(7, 'alex') }
|
|
298
|
+
assert.deepEqual(taskFromEvent(wrap(task)), task)
|
|
299
|
+
assert.equal(taskBelongsToAgent(taskFromEvent(wrap(task)), identity), true)
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
for (const field of ['type_id', 'typeId', 'status_id', 'statusId']) for (const state of ['done', 'review', 'active', 'no-pr']) {
|
|
304
|
+
add(`report-${field}-${state}`, 'completion-report', `${field} numeric-only ${state} state requires an actual handoff and PR evidence.`, () => {
|
|
305
|
+
const report = buildTaskCompletionReport({ id: 77, slug: 'OPEN-77', title: 'Fix build', [field]: state === 'done' ? 9 : state === 'active' ? 3 : 8, description: state === 'no-pr' ? '' : rootContent }, { completedTypeIds: new Set([9]), reviewTypeIds: new Set([8]) })
|
|
306
|
+
assert.equal(!!report, state === 'done' || state === 'review')
|
|
307
|
+
if (report) assert.match(report.content, state === 'done' ? /moved it to completed/ : /moved it to review/)
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for (const source of [100, '100', '90071992547409930']) {
|
|
312
|
+
add(`fresh-reply-${source}-${typeof source}`, 'reply-deduplication', `Only agent replies newer than source ${source} count as delivery.`, () => {
|
|
313
|
+
const id = BigInt(source)
|
|
314
|
+
const rows = [null, { id: String(id - 1n) }, { id: String(id) }, { id: String(id + 1n) }, { id: 'opaque' }]
|
|
315
|
+
assert.deepEqual(repliesAfterSource(rows, source), [{ id: String(id + 1n) }])
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
add('opaque-source-remains-deliverable', 'reply-deduplication', 'Opaque source IDs cannot be ordered and must not suppress a fresh answer.', () => {
|
|
319
|
+
assert.deepEqual(repliesAfterSource([{ id: 'a' }], 'b'), [])
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
for (const phase of ['cache-hit', 'coalesced-load', 'cancel-before', 'cancel-during', 'retry-after-failure', 'other-root', 'own-reply-not-root', 'channel-isolation']) {
|
|
323
|
+
add(`resolve-thread-${phase}`, 'thread-context-lifecycle', `Thread context resolver handles ${phase} without inventing ownership.`, async () => {
|
|
324
|
+
const store = new Map()
|
|
325
|
+
let loads = 0
|
|
326
|
+
let cancelled = phase === 'cancel-before'
|
|
327
|
+
let finish
|
|
328
|
+
if (phase === 'cache-hit') store.set('2:100', rootContent)
|
|
329
|
+
const resolve = createThreadContextResolver({
|
|
330
|
+
read: (channel, id) => store.get(`${channel}:${id}`), save: (channel, id, text) => store.set(`${channel}:${id}`, text), cancelled: () => cancelled, identity: () => identity,
|
|
331
|
+
load: async () => {
|
|
332
|
+
loads++
|
|
333
|
+
if (phase === 'retry-after-failure' && loads === 1) throw new Error('temporary read outage')
|
|
334
|
+
if (phase === 'cancel-during') return new Promise((done) => { finish = done })
|
|
335
|
+
return { message: phase === 'other-root' ? { ...root, sender_agent: { id: 8 } } : phase === 'own-reply-not-root' ? { ...root, parent_id: 90 } : root }
|
|
336
|
+
},
|
|
337
|
+
})
|
|
338
|
+
if (phase === 'retry-after-failure') await assert.rejects(resolve(2, 100), /temporary read outage/)
|
|
339
|
+
const first = resolve(2, 100)
|
|
340
|
+
if (phase === 'cancel-during') { await Promise.resolve(); cancelled = true; finish({ message: root }) }
|
|
341
|
+
const values = await Promise.all([first, resolve(2, 100)])
|
|
342
|
+
const owned = !['cancel-before', 'cancel-during', 'other-root', 'own-reply-not-root'].includes(phase)
|
|
343
|
+
assert.deepEqual(values, [owned ? rootContent : '', owned ? rootContent : ''])
|
|
344
|
+
assert.equal(loads, ['cache-hit', 'cancel-before'].includes(phase) ? 0 : phase === 'retry-after-failure' ? 2 : 1)
|
|
345
|
+
if (phase === 'channel-isolation') { assert.equal(await resolve(3, 100), rootContent); assert.equal(loads, 2) }
|
|
346
|
+
if (!owned) assert.equal(store.size, 0)
|
|
347
|
+
})
|
|
348
|
+
}
|