@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.
Files changed (54) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +8 -1
  3. package/build.mjs +1 -0
  4. package/dist/frontend/components/AiChatButton.js +1 -1
  5. package/dist/frontend/components/AiChatButton.js.map +2 -2
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +16 -5
  7. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +2 -2
  8. package/dist/modules/ai_assistant/ai-tools/meta-pack.js +58 -1
  9. package/dist/modules/ai_assistant/ai-tools/meta-pack.js.map +2 -2
  10. package/dist/modules/ai_assistant/api/ai/agents/route.js +2 -1
  11. package/dist/modules/ai_assistant/api/ai/agents/route.js.map +2 -2
  12. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  13. package/dist/modules/ai_assistant/i18n/de.json +7 -1
  14. package/dist/modules/ai_assistant/i18n/en.json +7 -1
  15. package/dist/modules/ai_assistant/i18n/es.json +7 -1
  16. package/dist/modules/ai_assistant/i18n/pl.json +7 -1
  17. package/dist/modules/ai_assistant/lib/agent-registry.js +26 -6
  18. package/dist/modules/ai_assistant/lib/agent-registry.js.map +2 -2
  19. package/dist/modules/ai_assistant/lib/agent-runtime.js +21 -8
  20. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +3 -3
  21. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  22. package/dist/modules/ai_assistant/lib/pending-action-types.js.map +2 -2
  23. package/dist/modules/ai_assistant/lib/prepare-mutation.js +16 -6
  24. package/dist/modules/ai_assistant/lib/prepare-mutation.js.map +2 -2
  25. package/dist/modules/ai_assistant/lib/task-plan-labels.js +95 -0
  26. package/dist/modules/ai_assistant/lib/task-plan-labels.js.map +7 -0
  27. package/dist/modules/ai_assistant/lib/task-plan-stream.js +349 -0
  28. package/dist/modules/ai_assistant/lib/task-plan-stream.js.map +7 -0
  29. package/dist/modules/ai_assistant/lib/tool-test-fixtures.js +3 -0
  30. package/dist/modules/ai_assistant/lib/tool-test-fixtures.js.map +2 -2
  31. package/package.json +6 -6
  32. package/src/frontend/components/AiChatButton.tsx +1 -1
  33. package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +20 -8
  34. package/src/modules/ai_assistant/ai-tools/__tests__/meta-pack.test.ts +60 -4
  35. package/src/modules/ai_assistant/ai-tools/meta-pack.ts +79 -2
  36. package/src/modules/ai_assistant/api/ai/agents/route.ts +2 -1
  37. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +1 -0
  38. package/src/modules/ai_assistant/i18n/de.json +7 -1
  39. package/src/modules/ai_assistant/i18n/en.json +7 -1
  40. package/src/modules/ai_assistant/i18n/es.json +7 -1
  41. package/src/modules/ai_assistant/i18n/pl.json +7 -1
  42. package/src/modules/ai_assistant/lib/__tests__/agent-registry.test.ts +60 -0
  43. package/src/modules/ai_assistant/lib/__tests__/ai-agent-definition.test.ts +4 -0
  44. package/src/modules/ai_assistant/lib/__tests__/prepare-mutation.test.ts +43 -0
  45. package/src/modules/ai_assistant/lib/__tests__/task-plan-stream.test.ts +375 -0
  46. package/src/modules/ai_assistant/lib/agent-registry.ts +36 -5
  47. package/src/modules/ai_assistant/lib/agent-runtime.ts +26 -8
  48. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +14 -0
  49. package/src/modules/ai_assistant/lib/pending-action-types.ts +4 -1
  50. package/src/modules/ai_assistant/lib/prepare-mutation.ts +17 -5
  51. package/src/modules/ai_assistant/lib/task-plan-labels.ts +112 -0
  52. package/src/modules/ai_assistant/lib/task-plan-stream.ts +463 -0
  53. package/src/modules/ai_assistant/lib/tool-test-fixtures.ts +3 -0
  54. 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 candidate
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 { ...candidate, defaultProvider: undefined }
150
+ return { ...taskPlanNormalized, defaultProvider: undefined }
122
151
  }
123
- return candidate
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
- agentsById.set(agent.id, {
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(baseResponse, preparedOptions.finalizeLoopTrace)
1645
+ return appendLoopFinishToStream(withTaskPlan, preparedOptions.finalizeLoopTrace)
1630
1646
  }
1631
- return baseResponse
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(baseResponse, preparedOptions.finalizeLoopTrace)
1675
+ return appendLoopFinishToStream(withTaskPlan, preparedOptions.finalizeLoopTrace)
1659
1676
  }
1660
- return baseResponse
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(baseResponse, preparedOptions.finalizeLoopTrace)
1710
+ return appendLoopFinishToStream(withTaskPlan, preparedOptions.finalizeLoopTrace)
1693
1711
  }
1694
- return baseResponse
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: Array<{ field: string; before: unknown; after: unknown }>
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
- diff.push({ field, before: beforeValue, after: afterValue })
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
  })