@open-mercato/ai-assistant 0.6.2-develop.3406.1.2b403f40da → 0.6.2-develop.3446.1.bd060c6017
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +8 -1
- package/build.mjs +1 -0
- package/dist/frontend/components/AiChatButton.js +1 -1
- package/dist/frontend/components/AiChatButton.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +16 -5
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +2 -2
- package/dist/modules/ai_assistant/ai-tools/meta-pack.js +58 -1
- package/dist/modules/ai_assistant/ai-tools/meta-pack.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/agents/route.js +2 -1
- package/dist/modules/ai_assistant/api/ai/agents/route.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +7 -1
- package/dist/modules/ai_assistant/i18n/en.json +7 -1
- package/dist/modules/ai_assistant/i18n/es.json +7 -1
- package/dist/modules/ai_assistant/i18n/pl.json +7 -1
- package/dist/modules/ai_assistant/lib/agent-registry.js +26 -6
- package/dist/modules/ai_assistant/lib/agent-registry.js.map +2 -2
- package/dist/modules/ai_assistant/lib/agent-runtime.js +21 -8
- package/dist/modules/ai_assistant/lib/agent-runtime.js.map +3 -3
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
- package/dist/modules/ai_assistant/lib/pending-action-types.js.map +2 -2
- package/dist/modules/ai_assistant/lib/prepare-mutation.js +16 -6
- package/dist/modules/ai_assistant/lib/prepare-mutation.js.map +2 -2
- package/dist/modules/ai_assistant/lib/task-plan-labels.js +95 -0
- package/dist/modules/ai_assistant/lib/task-plan-labels.js.map +7 -0
- package/dist/modules/ai_assistant/lib/task-plan-stream.js +349 -0
- package/dist/modules/ai_assistant/lib/task-plan-stream.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-test-fixtures.js +3 -0
- package/dist/modules/ai_assistant/lib/tool-test-fixtures.js.map +2 -2
- package/package.json +6 -6
- package/src/frontend/components/AiChatButton.tsx +1 -1
- package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +20 -8
- package/src/modules/ai_assistant/ai-tools/__tests__/meta-pack.test.ts +60 -4
- package/src/modules/ai_assistant/ai-tools/meta-pack.ts +79 -2
- package/src/modules/ai_assistant/api/ai/agents/route.ts +2 -1
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +1 -0
- package/src/modules/ai_assistant/i18n/de.json +7 -1
- package/src/modules/ai_assistant/i18n/en.json +7 -1
- package/src/modules/ai_assistant/i18n/es.json +7 -1
- package/src/modules/ai_assistant/i18n/pl.json +7 -1
- package/src/modules/ai_assistant/lib/__tests__/agent-registry.test.ts +60 -0
- package/src/modules/ai_assistant/lib/__tests__/ai-agent-definition.test.ts +4 -0
- package/src/modules/ai_assistant/lib/__tests__/prepare-mutation.test.ts +43 -0
- package/src/modules/ai_assistant/lib/__tests__/task-plan-stream.test.ts +375 -0
- package/src/modules/ai_assistant/lib/agent-registry.ts +36 -5
- package/src/modules/ai_assistant/lib/agent-runtime.ts +26 -8
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +14 -0
- package/src/modules/ai_assistant/lib/pending-action-types.ts +4 -1
- package/src/modules/ai_assistant/lib/prepare-mutation.ts +17 -5
- package/src/modules/ai_assistant/lib/task-plan-labels.ts +112 -0
- package/src/modules/ai_assistant/lib/task-plan-stream.ts +463 -0
- package/src/modules/ai_assistant/lib/tool-test-fixtures.ts +3 -0
- package/src/modules/ai_assistant/lib/types.ts +16 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the server-side task-plan SSE injector
|
|
3
|
+
* (`packages/ai-assistant/src/modules/ai_assistant/lib/task-plan-stream.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Covers spec `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`
|
|
6
|
+
* acceptance criteria:
|
|
7
|
+
* - runtime-derived labels from tool lifecycle chunks
|
|
8
|
+
* - agent-authored labels via the safe `meta.update_task_plan` tool
|
|
9
|
+
* - additive `data-agent-task-plan` snapshot + `data-agent-task-update` deltas
|
|
10
|
+
* - terminal-state ordering safeguard (done/failed/skipped wins over running)
|
|
11
|
+
* - non-tool chunks are passed through unchanged
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Node 18+ ships TextEncoder/TextDecoder/ReadableStream/Response globally,
|
|
15
|
+
// so no jsdom-style polyfills are required for this test. The
|
|
16
|
+
// `task-plan-stream` module relies on the same standard web stream APIs.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
TaskPlanAccumulator,
|
|
20
|
+
deriveTaskLabel,
|
|
21
|
+
injectTaskPlanIntoStream,
|
|
22
|
+
} from '../task-plan-stream'
|
|
23
|
+
|
|
24
|
+
function chunk(type: string, extras: Record<string, unknown> = {}): { type: string } & Record<string, unknown> {
|
|
25
|
+
return { type, ...extras }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseInjected(line: string): Record<string, unknown> {
|
|
29
|
+
return JSON.parse(line.replace('data: ', '').trim()) as Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('deriveTaskLabel', () => {
|
|
33
|
+
it('humanizes the last tool segment with module prefix', () => {
|
|
34
|
+
expect(deriveTaskLabel('customers__list_people')).toBe('Customers · List people')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('falls back to a generic label when name is missing', () => {
|
|
38
|
+
expect(deriveTaskLabel(undefined)).toBe('Tool call')
|
|
39
|
+
expect(deriveTaskLabel('')).toBe('Tool call')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('handles unprefixed tool names', () => {
|
|
43
|
+
expect(deriveTaskLabel('search')).toBe('Search')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('caps very long names at 80 chars', () => {
|
|
47
|
+
const huge = `module__${'segment_'.repeat(40)}end`
|
|
48
|
+
const label = deriveTaskLabel(huge)
|
|
49
|
+
expect(label.length).toBeLessThanOrEqual(80)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('TaskPlanAccumulator', () => {
|
|
54
|
+
it('emits an initial snapshot then patches via updates', () => {
|
|
55
|
+
const acc = new TaskPlanAccumulator('turn_test')
|
|
56
|
+
const first = acc.handleToolChunk(
|
|
57
|
+
chunk('tool-input-start', { toolCallId: 'call-1', toolName: 'customers__list_people' }),
|
|
58
|
+
)
|
|
59
|
+
expect(first).toHaveLength(1)
|
|
60
|
+
const firstParsed = JSON.parse(first[0]!.replace('data: ', '').trim())
|
|
61
|
+
expect(firstParsed).toMatchObject({
|
|
62
|
+
type: 'data-agent-task-plan',
|
|
63
|
+
planId: 'turn_test',
|
|
64
|
+
tasks: [
|
|
65
|
+
{
|
|
66
|
+
id: 'call-1',
|
|
67
|
+
label: 'Customers · List people',
|
|
68
|
+
state: 'running',
|
|
69
|
+
source: 'runtime',
|
|
70
|
+
toolCallId: 'call-1',
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const finishing = acc.handleToolChunk(
|
|
76
|
+
chunk('tool-output-available', { toolCallId: 'call-1' }),
|
|
77
|
+
)
|
|
78
|
+
expect(finishing).toHaveLength(1)
|
|
79
|
+
const finishParsed = JSON.parse(finishing[0]!.replace('data: ', '').trim())
|
|
80
|
+
expect(finishParsed).toMatchObject({
|
|
81
|
+
type: 'data-agent-task-update',
|
|
82
|
+
planId: 'turn_test',
|
|
83
|
+
task: { id: 'call-1', state: 'done' },
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('emits task-update for a second tool that arrives after the snapshot', () => {
|
|
88
|
+
const acc = new TaskPlanAccumulator('turn_test')
|
|
89
|
+
acc.handleToolChunk(chunk('tool-input-start', { toolCallId: 'call-1', toolName: 'a__first' }))
|
|
90
|
+
const secondStart = acc.handleToolChunk(
|
|
91
|
+
chunk('tool-input-start', { toolCallId: 'call-2', toolName: 'b__second' }),
|
|
92
|
+
)
|
|
93
|
+
expect(secondStart).toHaveLength(1)
|
|
94
|
+
const parsed = JSON.parse(secondStart[0]!.replace('data: ', '').trim())
|
|
95
|
+
expect(parsed.type).toBe('data-agent-task-update')
|
|
96
|
+
expect(parsed.task).toMatchObject({ id: 'call-2', state: 'running' })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('keeps terminal state when a later running event arrives out of order', () => {
|
|
100
|
+
const acc = new TaskPlanAccumulator('turn_test')
|
|
101
|
+
acc.handleToolChunk(chunk('tool-input-start', { toolCallId: 'call-1', toolName: 'a__first' }))
|
|
102
|
+
acc.handleToolChunk(chunk('tool-output-available', { toolCallId: 'call-1' }))
|
|
103
|
+
const stale = acc.handleToolChunk(
|
|
104
|
+
chunk('tool-input-available', { toolCallId: 'call-1', toolName: 'a__first', input: {} }),
|
|
105
|
+
)
|
|
106
|
+
const parsed = JSON.parse(stale[0]!.replace('data: ', '').trim())
|
|
107
|
+
// Stream-ordering safeguard: terminal `done` wins over a late `running`.
|
|
108
|
+
expect(parsed.task.state).toBe('done')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('marks tool errors as failed', () => {
|
|
112
|
+
const acc = new TaskPlanAccumulator('turn_test')
|
|
113
|
+
acc.handleToolChunk(chunk('tool-input-start', { toolCallId: 'call-1', toolName: 'a__first' }))
|
|
114
|
+
const fail = acc.handleToolChunk(
|
|
115
|
+
chunk('tool-output-error', { toolCallId: 'call-1', errorText: 'boom' }),
|
|
116
|
+
)
|
|
117
|
+
const parsed = JSON.parse(fail[0]!.replace('data: ', '').trim())
|
|
118
|
+
expect(parsed.task.state).toBe('failed')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('ignores chunks without a toolCallId', () => {
|
|
122
|
+
const acc = new TaskPlanAccumulator('turn_test')
|
|
123
|
+
expect(acc.handleToolChunk(chunk('tool-input-start'))).toEqual([])
|
|
124
|
+
expect(acc.handleToolChunk(chunk('text-delta', { delta: 'hi' }))).toEqual([])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('emits a safe agent-authored plan from meta.update_task_plan input', () => {
|
|
128
|
+
const acc = new TaskPlanAccumulator('turn_test')
|
|
129
|
+
const emitted = acc.handleToolChunk(
|
|
130
|
+
chunk('tool-input-available', {
|
|
131
|
+
toolCallId: 'plan-call',
|
|
132
|
+
toolName: 'meta__update_task_plan',
|
|
133
|
+
input: {
|
|
134
|
+
tasks: [
|
|
135
|
+
{
|
|
136
|
+
id: 'find-products',
|
|
137
|
+
label: 'Find matching products',
|
|
138
|
+
detail: 'Catalog search',
|
|
139
|
+
toolName: 'catalog.search_products',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
label: 'Summarize useful matches',
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
)
|
|
148
|
+
expect(emitted).toHaveLength(1)
|
|
149
|
+
const parsed = parseInjected(emitted[0]!)
|
|
150
|
+
expect(parsed).toMatchObject({
|
|
151
|
+
type: 'data-agent-task-plan',
|
|
152
|
+
planId: 'turn_test',
|
|
153
|
+
tasks: [
|
|
154
|
+
{
|
|
155
|
+
id: 'find-products',
|
|
156
|
+
label: 'Find matching products',
|
|
157
|
+
detail: 'Catalog search',
|
|
158
|
+
state: 'pending',
|
|
159
|
+
source: 'agent',
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: 'agent-plan-2',
|
|
163
|
+
label: 'Summarize useful matches',
|
|
164
|
+
state: 'pending',
|
|
165
|
+
source: 'agent',
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('drops hidden-reasoning-like agent task labels', () => {
|
|
172
|
+
const acc = new TaskPlanAccumulator('turn_test')
|
|
173
|
+
const emitted = acc.handleToolChunk(
|
|
174
|
+
chunk('tool-input-available', {
|
|
175
|
+
toolCallId: 'plan-call',
|
|
176
|
+
toolName: 'meta.update_task_plan',
|
|
177
|
+
input: {
|
|
178
|
+
tasks: [
|
|
179
|
+
{
|
|
180
|
+
id: 'bad',
|
|
181
|
+
label: '<thinking>inspect tenant data</thinking>',
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
)
|
|
187
|
+
expect(emitted).toEqual([])
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('updates an agent-authored step when the mapped tool runs and finishes', () => {
|
|
191
|
+
const acc = new TaskPlanAccumulator('turn_test')
|
|
192
|
+
acc.handleToolChunk(
|
|
193
|
+
chunk('tool-input-available', {
|
|
194
|
+
toolCallId: 'plan-call',
|
|
195
|
+
toolName: 'meta__update_task_plan',
|
|
196
|
+
input: {
|
|
197
|
+
tasks: [
|
|
198
|
+
{
|
|
199
|
+
id: 'catalog-search',
|
|
200
|
+
label: 'Search the catalog',
|
|
201
|
+
toolName: 'catalog.search_products',
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
)
|
|
207
|
+
const running = acc.handleToolChunk(
|
|
208
|
+
chunk('tool-input-start', {
|
|
209
|
+
toolCallId: 'call-1',
|
|
210
|
+
toolName: 'catalog__search_products',
|
|
211
|
+
}),
|
|
212
|
+
)
|
|
213
|
+
expect(parseInjected(running[0]!)).toMatchObject({
|
|
214
|
+
type: 'data-agent-task-update',
|
|
215
|
+
task: {
|
|
216
|
+
id: 'catalog-search',
|
|
217
|
+
label: 'Search the catalog',
|
|
218
|
+
state: 'running',
|
|
219
|
+
source: 'agent',
|
|
220
|
+
toolCallId: 'call-1',
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const done = acc.handleToolChunk(chunk('tool-output-available', { toolCallId: 'call-1' }))
|
|
225
|
+
expect(parseInjected(done[0]!)).toMatchObject({
|
|
226
|
+
type: 'data-agent-task-update',
|
|
227
|
+
task: {
|
|
228
|
+
id: 'catalog-search',
|
|
229
|
+
label: 'Search the catalog',
|
|
230
|
+
state: 'done',
|
|
231
|
+
source: 'agent',
|
|
232
|
+
toolCallId: 'call-1',
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
function buildSseResponse(events: Array<Record<string, unknown>>): Response {
|
|
239
|
+
const encoder = new TextEncoder()
|
|
240
|
+
const body = new ReadableStream<Uint8Array>({
|
|
241
|
+
start(controller) {
|
|
242
|
+
const raw = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join('')
|
|
243
|
+
controller.enqueue(encoder.encode(raw))
|
|
244
|
+
controller.close()
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
return new Response(body, {
|
|
248
|
+
status: 200,
|
|
249
|
+
headers: {
|
|
250
|
+
'Content-Type': 'text/event-stream',
|
|
251
|
+
'x-vercel-ai-ui-message-stream': 'v1',
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function readEvents(response: Response): Promise<Array<Record<string, unknown>>> {
|
|
257
|
+
const reader = response.body!.getReader()
|
|
258
|
+
const decoder = new TextDecoder()
|
|
259
|
+
let buffer = ''
|
|
260
|
+
const events: Array<Record<string, unknown>> = []
|
|
261
|
+
for (;;) {
|
|
262
|
+
const { value, done } = await reader.read()
|
|
263
|
+
if (done) break
|
|
264
|
+
buffer += decoder.decode(value, { stream: true })
|
|
265
|
+
for (;;) {
|
|
266
|
+
const boundary = buffer.indexOf('\n\n')
|
|
267
|
+
if (boundary === -1) break
|
|
268
|
+
const block = buffer.slice(0, boundary)
|
|
269
|
+
buffer = buffer.slice(boundary + 2)
|
|
270
|
+
const dataLine = block.split('\n').find((line) => line.startsWith('data: '))
|
|
271
|
+
if (!dataLine) continue
|
|
272
|
+
const payload = dataLine.slice('data: '.length)
|
|
273
|
+
if (payload === '[DONE]') continue
|
|
274
|
+
try {
|
|
275
|
+
events.push(JSON.parse(payload))
|
|
276
|
+
} catch {
|
|
277
|
+
// ignore malformed
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return events
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
describe('injectTaskPlanIntoStream', () => {
|
|
285
|
+
it('passes through original chunks while injecting task-plan events', async () => {
|
|
286
|
+
const base = buildSseResponse([
|
|
287
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'catalog__search_products' },
|
|
288
|
+
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'catalog__search_products', input: { q: 'shoe' } },
|
|
289
|
+
{ type: 'tool-output-available', toolCallId: 'call-1', output: { count: 3 } },
|
|
290
|
+
{ type: 'text-delta', id: 'text-1', delta: 'Done.' },
|
|
291
|
+
])
|
|
292
|
+
const wrapped = injectTaskPlanIntoStream(base, 'turn_42')
|
|
293
|
+
const events = await readEvents(wrapped)
|
|
294
|
+
const planEvent = events.find((e) => e.type === 'data-agent-task-plan')
|
|
295
|
+
expect(planEvent).toMatchObject({ planId: 'turn_42' })
|
|
296
|
+
// Snapshot should appear before the first tool-input-start passthrough.
|
|
297
|
+
const planIndex = events.findIndex((e) => e.type === 'data-agent-task-plan')
|
|
298
|
+
const startIndex = events.findIndex((e) => e.type === 'tool-input-start')
|
|
299
|
+
expect(planIndex).toBeLessThan(startIndex)
|
|
300
|
+
// A `done` update should be emitted after the tool-output-available.
|
|
301
|
+
const outputIndex = events.findIndex((e) => e.type === 'tool-output-available')
|
|
302
|
+
const doneUpdateIndex = events.findIndex(
|
|
303
|
+
(e) =>
|
|
304
|
+
e.type === 'data-agent-task-update' &&
|
|
305
|
+
(e.task as { state?: string } | undefined)?.state === 'done',
|
|
306
|
+
)
|
|
307
|
+
expect(doneUpdateIndex).toBeGreaterThan(outputIndex)
|
|
308
|
+
// Text-delta is forwarded unchanged.
|
|
309
|
+
const textEvent = events.find((e) => e.type === 'text-delta')
|
|
310
|
+
expect(textEvent).toMatchObject({ delta: 'Done.' })
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('injects an agent-authored plan before the meta tool input passthrough', async () => {
|
|
314
|
+
const base = buildSseResponse([
|
|
315
|
+
{ type: 'tool-input-start', toolCallId: 'plan-call', toolName: 'meta__update_task_plan' },
|
|
316
|
+
{
|
|
317
|
+
type: 'tool-input-available',
|
|
318
|
+
toolCallId: 'plan-call',
|
|
319
|
+
toolName: 'meta__update_task_plan',
|
|
320
|
+
input: {
|
|
321
|
+
tasks: [
|
|
322
|
+
{
|
|
323
|
+
id: 'search-step',
|
|
324
|
+
label: 'Search matching products',
|
|
325
|
+
toolName: 'catalog.search_products',
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
{ type: 'tool-output-available', toolCallId: 'plan-call', output: { ok: true } },
|
|
331
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'catalog__search_products' },
|
|
332
|
+
])
|
|
333
|
+
const wrapped = injectTaskPlanIntoStream(base, 'turn_agent')
|
|
334
|
+
const events = await readEvents(wrapped)
|
|
335
|
+
const planIndex = events.findIndex((e) => e.type === 'data-agent-task-plan')
|
|
336
|
+
const metaInputIndex = events.findIndex(
|
|
337
|
+
(e) => e.type === 'tool-input-available' && e.toolCallId === 'plan-call',
|
|
338
|
+
)
|
|
339
|
+
const domainStartIndex = events.findIndex(
|
|
340
|
+
(e) => e.type === 'tool-input-start' && e.toolCallId === 'call-1',
|
|
341
|
+
)
|
|
342
|
+
expect(planIndex).toBeGreaterThan(-1)
|
|
343
|
+
expect(planIndex).toBeLessThan(metaInputIndex)
|
|
344
|
+
expect(planIndex).toBeLessThan(domainStartIndex)
|
|
345
|
+
expect(events[planIndex]).toMatchObject({
|
|
346
|
+
tasks: [{ id: 'search-step', label: 'Search matching products', source: 'agent' }],
|
|
347
|
+
})
|
|
348
|
+
expect(
|
|
349
|
+
events.some(
|
|
350
|
+
(e) =>
|
|
351
|
+
(e.type === 'data-agent-task-update' || e.type === 'data-agent-task-plan') &&
|
|
352
|
+
JSON.stringify(e).includes('plan-call'),
|
|
353
|
+
),
|
|
354
|
+
).toBe(false)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('does not inject anything when there are no tool events', async () => {
|
|
358
|
+
const base = buildSseResponse([
|
|
359
|
+
{ type: 'text-delta', id: 'text-1', delta: 'Hello.' },
|
|
360
|
+
{ type: 'reasoning-start' },
|
|
361
|
+
{ type: 'reasoning-delta', delta: '...' },
|
|
362
|
+
{ type: 'reasoning-end' },
|
|
363
|
+
])
|
|
364
|
+
const wrapped = injectTaskPlanIntoStream(base, 'turn_43')
|
|
365
|
+
const events = await readEvents(wrapped)
|
|
366
|
+
expect(events.some((e) => e.type === 'data-agent-task-plan')).toBe(false)
|
|
367
|
+
expect(events.some((e) => e.type === 'data-agent-task-update')).toBe(false)
|
|
368
|
+
expect(events.map((e) => e.type)).toEqual([
|
|
369
|
+
'text-delta',
|
|
370
|
+
'reasoning-start',
|
|
371
|
+
'reasoning-delta',
|
|
372
|
+
'reasoning-end',
|
|
373
|
+
])
|
|
374
|
+
})
|
|
375
|
+
})
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type AiAgentExtensionConfigEntry,
|
|
8
8
|
type AiAgentOverrideConfigEntry,
|
|
9
9
|
} from './ai-overrides'
|
|
10
|
+
import { TASK_PLAN_TOOL_NAME } from './task-plan-labels'
|
|
10
11
|
|
|
11
12
|
const agentsById = new Map<string, AiAgentDefinition>()
|
|
12
13
|
let loaded = false
|
|
@@ -29,6 +30,11 @@ function isAiAgentExtension(value: unknown): value is AiAgentExtension {
|
|
|
29
30
|
(!('replaceAllowedTools' in candidate) || isStringArray(candidate.replaceAllowedTools)) &&
|
|
30
31
|
(!('deleteAllowedTools' in candidate) || isStringArray(candidate.deleteAllowedTools)) &&
|
|
31
32
|
(!('appendAllowedTools' in candidate) || isStringArray(candidate.appendAllowedTools)) &&
|
|
33
|
+
(!('taskPlan' in candidate) ||
|
|
34
|
+
(candidate.taskPlan != null &&
|
|
35
|
+
typeof candidate.taskPlan === 'object' &&
|
|
36
|
+
(!('enabled' in candidate.taskPlan) ||
|
|
37
|
+
typeof (candidate.taskPlan as { enabled?: unknown }).enabled === 'boolean'))) &&
|
|
32
38
|
(!('replaceSystemPrompt' in candidate) || typeof candidate.replaceSystemPrompt === 'string') &&
|
|
33
39
|
(!('appendSystemPrompt' in candidate) || typeof candidate.appendSystemPrompt === 'string') &&
|
|
34
40
|
(!('replaceSuggestions' in candidate) ||
|
|
@@ -58,6 +64,28 @@ function uniqueStrings(values: readonly string[]): string[] {
|
|
|
58
64
|
return Array.from(new Set(values.filter((value) => typeof value === 'string' && value.length > 0)))
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
export function isAgentTaskPlanEnabled(agent: Pick<AiAgentDefinition, 'taskPlan'>): boolean {
|
|
68
|
+
return agent.taskPlan?.enabled === true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeTaskPlanTool(agent: AiAgentDefinition): AiAgentDefinition {
|
|
72
|
+
const enabled = isAgentTaskPlanEnabled(agent)
|
|
73
|
+
const withoutInternalTool = agent.allowedTools.filter((toolName) => toolName !== TASK_PLAN_TOOL_NAME)
|
|
74
|
+
const allowedTools = enabled
|
|
75
|
+
? uniqueStrings([...withoutInternalTool, TASK_PLAN_TOOL_NAME])
|
|
76
|
+
: uniqueStrings(withoutInternalTool)
|
|
77
|
+
if (
|
|
78
|
+
allowedTools.length === agent.allowedTools.length &&
|
|
79
|
+
allowedTools.every((toolName, index) => toolName === agent.allowedTools[index])
|
|
80
|
+
) {
|
|
81
|
+
return agent
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
...agent,
|
|
85
|
+
allowedTools,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
61
89
|
function applyStringListPatch(
|
|
62
90
|
current: readonly string[],
|
|
63
91
|
patch: {
|
|
@@ -106,9 +134,10 @@ function applySuggestionPatch(
|
|
|
106
134
|
}
|
|
107
135
|
|
|
108
136
|
function validateAndNormalizeAgent(candidate: AiAgentDefinition): AiAgentDefinition {
|
|
137
|
+
const taskPlanNormalized = normalizeTaskPlanTool(candidate)
|
|
109
138
|
const rawProvider = candidate.defaultProvider
|
|
110
139
|
if (typeof rawProvider !== 'string' || rawProvider.trim().length === 0) {
|
|
111
|
-
return
|
|
140
|
+
return taskPlanNormalized
|
|
112
141
|
}
|
|
113
142
|
const providerHint = rawProvider.trim()
|
|
114
143
|
const registered = llmProviderRegistry.get(providerHint)
|
|
@@ -118,9 +147,9 @@ function validateAndNormalizeAgent(candidate: AiAgentDefinition): AiAgentDefinit
|
|
|
118
147
|
`The agent will be registered with defaultProvider: undefined so the resolution chain still works. ` +
|
|
119
148
|
`Built-in provider ids: anthropic, google, openai, deepinfra, groq, together, fireworks, azure, litellm, ollama.`,
|
|
120
149
|
)
|
|
121
|
-
return { ...
|
|
150
|
+
return { ...taskPlanNormalized, defaultProvider: undefined }
|
|
122
151
|
}
|
|
123
|
-
return
|
|
152
|
+
return taskPlanNormalized
|
|
124
153
|
}
|
|
125
154
|
|
|
126
155
|
function populateFromAgents(agents: unknown[]): void {
|
|
@@ -193,13 +222,14 @@ function applyExtensionsToRegistry(extensions: readonly AiAgentExtension[]): voi
|
|
|
193
222
|
const replacementSystemPrompt = extension.replaceSystemPrompt?.trim()
|
|
194
223
|
const appendSystemPrompt = extension.appendSystemPrompt?.trim()
|
|
195
224
|
const systemPrompt = replacementSystemPrompt ?? agent.systemPrompt.trim()
|
|
196
|
-
|
|
225
|
+
const patchedAgent: AiAgentDefinition = {
|
|
197
226
|
...agent,
|
|
198
227
|
allowedTools: applyStringListPatch(agent.allowedTools, {
|
|
199
228
|
replace: extension.replaceAllowedTools,
|
|
200
229
|
delete: extension.deleteAllowedTools,
|
|
201
230
|
append: extension.appendAllowedTools,
|
|
202
231
|
}),
|
|
232
|
+
taskPlan: extension.taskPlan !== undefined ? extension.taskPlan : agent.taskPlan,
|
|
203
233
|
systemPrompt: appendSystemPrompt
|
|
204
234
|
? `${systemPrompt}\n\n${appendSystemPrompt}`
|
|
205
235
|
: systemPrompt,
|
|
@@ -211,7 +241,8 @@ function applyExtensionsToRegistry(extensions: readonly AiAgentExtension[]): voi
|
|
|
211
241
|
...(extension.suggestions ?? []),
|
|
212
242
|
],
|
|
213
243
|
}),
|
|
214
|
-
}
|
|
244
|
+
}
|
|
245
|
+
agentsById.set(agent.id, validateAndNormalizeAgent(patchedAgent))
|
|
215
246
|
}
|
|
216
247
|
}
|
|
217
248
|
|
|
@@ -57,9 +57,12 @@ import { AiAgentRuntimeOverrideRepository } from '../data/repositories/AiAgentRu
|
|
|
57
57
|
import { AiTenantModelAllowlistRepository } from '../data/repositories/AiTenantModelAllowlistRepository'
|
|
58
58
|
import type { TenantAllowlistSnapshot } from './model-allowlist'
|
|
59
59
|
import { composeSystemPromptWithOverride } from './prompt-override-merge'
|
|
60
|
+
import { isAgentTaskPlanEnabled } from './agent-registry'
|
|
60
61
|
import { isKnownMutationPolicy } from './agent-policy'
|
|
61
62
|
import type { AiAgentMutationPolicy } from './ai-agent-definition'
|
|
62
63
|
import { recordTokenUsage } from './token-usage-recorder'
|
|
64
|
+
import { injectTaskPlanIntoStream } from './task-plan-stream'
|
|
65
|
+
import { TASK_PLAN_RUNTIME_PROMPT_SECTION } from './task-plan-labels'
|
|
63
66
|
|
|
64
67
|
// Ensure built-in LLM providers are registered. Side-effect import; identical to
|
|
65
68
|
// what `./ai-sdk.ts` consumers already rely on.
|
|
@@ -1416,6 +1419,11 @@ function appendRuntimeMutationPolicy(
|
|
|
1416
1419
|
return `${systemPrompt}\n\n${block}`
|
|
1417
1420
|
}
|
|
1418
1421
|
|
|
1422
|
+
function appendRuntimeTaskPlanPrompt(systemPrompt: string, agent: Pick<AiAgentDefinition, 'taskPlan'>): string {
|
|
1423
|
+
if (!isAgentTaskPlanEnabled(agent)) return systemPrompt
|
|
1424
|
+
return `${systemPrompt}\n\n${TASK_PLAN_RUNTIME_PROMPT_SECTION}`
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1419
1427
|
/**
|
|
1420
1428
|
* Server-side helper that runs an Open Mercato agent in chat mode via the
|
|
1421
1429
|
* Vercel AI SDK and returns a streaming `Response` ready to be emitted from a
|
|
@@ -1482,7 +1490,7 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
|
|
|
1482
1490
|
input.authContext.organizationId,
|
|
1483
1491
|
)
|
|
1484
1492
|
const systemPrompt = appendRuntimeMutationPolicy(
|
|
1485
|
-
appendAttachmentSummary(baseSystemPrompt, resolvedAttachments),
|
|
1493
|
+
appendRuntimeTaskPlanPrompt(appendAttachmentSummary(baseSystemPrompt, resolvedAttachments), agent),
|
|
1486
1494
|
agent,
|
|
1487
1495
|
mutationPolicyOverride,
|
|
1488
1496
|
)
|
|
@@ -1615,6 +1623,13 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
|
|
|
1615
1623
|
...(builtToolLoopAgent !== undefined ? { toolLoopAgent: builtToolLoopAgent } : {}),
|
|
1616
1624
|
}
|
|
1617
1625
|
|
|
1626
|
+
// Phase 1 of `2026-05-13-ai-chat-visible-task-plan` — every chat-mode
|
|
1627
|
+
// response stream is wrapped in a task-plan injector. The injector is
|
|
1628
|
+
// additive and keyed by the per-turn `turnId` so old clients that ignore
|
|
1629
|
+
// unknown chunks keep working; current clients render only agent-authored
|
|
1630
|
+
// plan rows and leave raw lifecycle progress in the tool-call details.
|
|
1631
|
+
const taskPlanId = `turn_${turnId}`
|
|
1632
|
+
|
|
1618
1633
|
if (input.generateText) {
|
|
1619
1634
|
try {
|
|
1620
1635
|
const callbackResult = await input.generateText(preparedOptions)
|
|
@@ -1625,10 +1640,11 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
|
|
|
1625
1640
|
Connection: 'keep-alive',
|
|
1626
1641
|
},
|
|
1627
1642
|
})
|
|
1643
|
+
const withTaskPlan = injectTaskPlanIntoStream(baseResponse, taskPlanId)
|
|
1628
1644
|
if (input.emitLoopTrace) {
|
|
1629
|
-
return appendLoopFinishToStream(
|
|
1645
|
+
return appendLoopFinishToStream(withTaskPlan, preparedOptions.finalizeLoopTrace)
|
|
1630
1646
|
}
|
|
1631
|
-
return
|
|
1647
|
+
return withTaskPlan
|
|
1632
1648
|
} finally {
|
|
1633
1649
|
if (wallClockTimer !== undefined) clearTimeout(wallClockTimer)
|
|
1634
1650
|
}
|
|
@@ -1654,10 +1670,11 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
|
|
|
1654
1670
|
Connection: 'keep-alive',
|
|
1655
1671
|
},
|
|
1656
1672
|
})
|
|
1673
|
+
const withTaskPlan = injectTaskPlanIntoStream(baseResponse, taskPlanId)
|
|
1657
1674
|
if (input.emitLoopTrace) {
|
|
1658
|
-
return appendLoopFinishToStream(
|
|
1675
|
+
return appendLoopFinishToStream(withTaskPlan, preparedOptions.finalizeLoopTrace)
|
|
1659
1676
|
}
|
|
1660
|
-
return
|
|
1677
|
+
return withTaskPlan
|
|
1661
1678
|
}
|
|
1662
1679
|
|
|
1663
1680
|
// Default stream-text path (executionEngine === 'stream-text' or unset).
|
|
@@ -1688,10 +1705,11 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
|
|
|
1688
1705
|
Connection: 'keep-alive',
|
|
1689
1706
|
},
|
|
1690
1707
|
})
|
|
1708
|
+
const withTaskPlan = injectTaskPlanIntoStream(baseResponse, taskPlanId)
|
|
1691
1709
|
if (input.emitLoopTrace) {
|
|
1692
|
-
return appendLoopFinishToStream(
|
|
1710
|
+
return appendLoopFinishToStream(withTaskPlan, preparedOptions.finalizeLoopTrace)
|
|
1693
1711
|
}
|
|
1694
|
-
return
|
|
1712
|
+
return withTaskPlan
|
|
1695
1713
|
}
|
|
1696
1714
|
|
|
1697
1715
|
/**
|
|
@@ -1914,7 +1932,7 @@ export async function runAiAgentObject<TSchema = unknown>(
|
|
|
1914
1932
|
input.authContext.organizationId,
|
|
1915
1933
|
)
|
|
1916
1934
|
const systemPrompt = appendRuntimeMutationPolicy(
|
|
1917
|
-
appendAttachmentSummary(baseSystemPrompt, resolvedAttachments),
|
|
1935
|
+
appendRuntimeTaskPlanPrompt(appendAttachmentSummary(baseSystemPrompt, resolvedAttachments), agent),
|
|
1918
1936
|
agent,
|
|
1919
1937
|
mutationPolicyOverride,
|
|
1920
1938
|
)
|
|
@@ -275,6 +275,18 @@ export interface AiAgentSuggestion {
|
|
|
275
275
|
prompt: string
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
export interface AiAgentTaskPlanConfig {
|
|
279
|
+
/**
|
|
280
|
+
* Enables the optional visible planning helper for this agent. When true,
|
|
281
|
+
* the runtime exposes `meta.update_task_plan` and injects prompt guidance
|
|
282
|
+
* telling the model to set a user-visible plan before domain tools.
|
|
283
|
+
*
|
|
284
|
+
* Defaults to false. CRM/customer agents enable this by default in core;
|
|
285
|
+
* other agents can opt in from their agent definition or extension config.
|
|
286
|
+
*/
|
|
287
|
+
enabled?: boolean
|
|
288
|
+
}
|
|
289
|
+
|
|
278
290
|
export interface AiAgentDefinition {
|
|
279
291
|
id: string
|
|
280
292
|
moduleId: string
|
|
@@ -368,6 +380,7 @@ export interface AiAgentDefinition {
|
|
|
368
380
|
allowRuntimeModelOverride?: boolean
|
|
369
381
|
acceptedMediaTypes?: AiAgentAcceptedMediaType[]
|
|
370
382
|
requiredFeatures?: string[]
|
|
383
|
+
taskPlan?: AiAgentTaskPlanConfig
|
|
371
384
|
uiParts?: string[]
|
|
372
385
|
readOnly?: boolean
|
|
373
386
|
mutationPolicy?: AiAgentMutationPolicy
|
|
@@ -400,6 +413,7 @@ export interface AiAgentExtension {
|
|
|
400
413
|
replaceAllowedTools?: string[]
|
|
401
414
|
deleteAllowedTools?: string[]
|
|
402
415
|
appendAllowedTools?: string[]
|
|
416
|
+
taskPlan?: AiAgentTaskPlanConfig
|
|
403
417
|
replaceSystemPrompt?: string
|
|
404
418
|
appendSystemPrompt?: string
|
|
405
419
|
replaceSuggestions?: AiAgentSuggestion[]
|
|
@@ -65,7 +65,7 @@ export type AiPendingActionRecordDiff = {
|
|
|
65
65
|
recordId: string
|
|
66
66
|
entityType: string
|
|
67
67
|
label: string
|
|
68
|
-
fieldDiff:
|
|
68
|
+
fieldDiff: AiPendingActionFieldDiff[]
|
|
69
69
|
recordVersion: string | null
|
|
70
70
|
attachmentIds?: string[]
|
|
71
71
|
}
|
|
@@ -81,8 +81,11 @@ export type AiPendingActionFailedRecord = {
|
|
|
81
81
|
|
|
82
82
|
export type AiPendingActionFieldDiff = {
|
|
83
83
|
field: string
|
|
84
|
+
fieldLabel?: string
|
|
84
85
|
before: unknown
|
|
85
86
|
after: unknown
|
|
87
|
+
beforeDisplay?: unknown
|
|
88
|
+
afterDisplay?: unknown
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
/**
|
|
@@ -5,6 +5,7 @@ import type { AiAgentDefinition, AiAgentMutationPolicy } from './ai-agent-defini
|
|
|
5
5
|
import type { AiChatRequestContext, AiUiPart } from './attachment-bridge-types'
|
|
6
6
|
import type {
|
|
7
7
|
AiToolDefinition,
|
|
8
|
+
AiToolFieldDiffDisplayHints,
|
|
8
9
|
AiToolLoadBeforeRecord,
|
|
9
10
|
AiToolLoadBeforeSingleRecord,
|
|
10
11
|
McpToolContext,
|
|
@@ -163,6 +164,7 @@ export function computeMutationIdempotencyKey(input: {
|
|
|
163
164
|
function computeFieldDiff(
|
|
164
165
|
before: Record<string, unknown>,
|
|
165
166
|
after: Record<string, unknown>,
|
|
167
|
+
display?: AiToolFieldDiffDisplayHints,
|
|
166
168
|
): AiPendingActionFieldDiff[] {
|
|
167
169
|
const diff: AiPendingActionFieldDiff[] = []
|
|
168
170
|
const keys = new Set<string>([
|
|
@@ -173,7 +175,17 @@ function computeFieldDiff(
|
|
|
173
175
|
const beforeValue = before ? before[field] : undefined
|
|
174
176
|
const afterValue = after ? after[field] : undefined
|
|
175
177
|
if (!Object.is(beforeValue, afterValue) && safeStringify(beforeValue) !== safeStringify(afterValue)) {
|
|
176
|
-
|
|
178
|
+
const fieldLabel = display?.fieldLabels?.[field]
|
|
179
|
+
const beforeDisplay = display?.before?.[field]
|
|
180
|
+
const afterDisplay = display?.after?.[field]
|
|
181
|
+
diff.push({
|
|
182
|
+
field,
|
|
183
|
+
...(fieldLabel !== undefined ? { fieldLabel } : {}),
|
|
184
|
+
before: beforeValue,
|
|
185
|
+
after: afterValue,
|
|
186
|
+
...(beforeDisplay !== undefined ? { beforeDisplay } : {}),
|
|
187
|
+
...(afterDisplay !== undefined ? { afterDisplay } : {}),
|
|
188
|
+
})
|
|
177
189
|
}
|
|
178
190
|
}
|
|
179
191
|
return diff
|
|
@@ -276,8 +288,8 @@ async function buildSingleRecordDiff(
|
|
|
276
288
|
sideEffectsSummary: null,
|
|
277
289
|
}
|
|
278
290
|
}
|
|
279
|
-
const patch = extractPatchFromArgs(input.toolCallArgs)
|
|
280
|
-
const fieldDiff = computeFieldDiff(before.before, patch)
|
|
291
|
+
const patch = before.after ?? extractPatchFromArgs(input.toolCallArgs)
|
|
292
|
+
const fieldDiff = computeFieldDiff(before.before, patch, before.display)
|
|
281
293
|
return {
|
|
282
294
|
fieldDiff,
|
|
283
295
|
targetEntityType: before.entityType,
|
|
@@ -320,12 +332,12 @@ async function buildBatchRecords(
|
|
|
320
332
|
}
|
|
321
333
|
}
|
|
322
334
|
const diffs: AiPendingActionRecordDiff[] = rows.map((row) => {
|
|
323
|
-
const patch = matchBatchPatch(input.toolCallArgs, row.recordId)
|
|
335
|
+
const patch = row.after ?? matchBatchPatch(input.toolCallArgs, row.recordId)
|
|
324
336
|
return {
|
|
325
337
|
recordId: row.recordId,
|
|
326
338
|
entityType: row.entityType,
|
|
327
339
|
label: row.label,
|
|
328
|
-
fieldDiff: computeFieldDiff(row.before, patch),
|
|
340
|
+
fieldDiff: computeFieldDiff(row.before, patch, row.display),
|
|
329
341
|
recordVersion: row.recordVersion ?? null,
|
|
330
342
|
}
|
|
331
343
|
})
|