@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,112 @@
1
+ /**
2
+ * Safe, user-visible task-plan labels for AI chat.
3
+ *
4
+ * These helpers intentionally reject text that looks like private reasoning.
5
+ * Task plans are UI copy for operators, not a channel for model scratchpads.
6
+ */
7
+
8
+ export const TASK_PLAN_TOOL_NAME = 'meta.update_task_plan'
9
+ export const TASK_PLAN_TOOL_NAME_SDK = 'meta__update_task_plan'
10
+ export const TASK_PLAN_MAX_TASKS = 8
11
+ export const TASK_PLAN_LABEL_MAX_CHARS = 80
12
+ export const TASK_PLAN_DETAIL_MAX_CHARS = 160
13
+ export const TASK_PLAN_ID_MAX_CHARS = 80
14
+ export const TASK_PLAN_TOOL_NAME_MAX_CHARS = 160
15
+
16
+ export interface SanitizedAgentTaskPlanInputTask {
17
+ id?: string
18
+ label: string
19
+ detail?: string
20
+ toolName?: string
21
+ }
22
+
23
+ export interface SanitizedAgentTaskPlanInput {
24
+ tasks: SanitizedAgentTaskPlanInputTask[]
25
+ }
26
+
27
+ export const TASK_PLAN_RUNTIME_PROMPT_SECTION = [
28
+ 'TASK PLAN (RUNTIME)',
29
+ 'For every tool-using turn, first call `meta.update_task_plan` with 2-5 concise user-visible steps. Then call the domain/search/attachment/mutation tools.',
30
+ 'Task labels are visible progress UI. Never include hidden reasoning, chain-of-thought, scratchpad notes, or XML thinking tags.',
31
+ 'When a planned step maps to a known tool, include `toolName` so the chat can advance that row from pending to running to done.',
32
+ 'Skip `meta.update_task_plan` for pure capability, example-question, or how-can-you-help prompts where no data tool is needed.',
33
+ ].join('\n')
34
+
35
+ const HIDDEN_REASONING_PATTERNS: RegExp[] = [
36
+ /\bchain[-\s]?of[-\s]?thought\b/i,
37
+ /\binternal\s+(?:reasoning|thoughts?)\b/i,
38
+ /\bprivate\s+(?:reasoning|thoughts?)\b/i,
39
+ /\bhidden\s+(?:reasoning|thoughts?)\b/i,
40
+ /\bscratch\s*pad\b/i,
41
+ /\bscratchpad\b/i,
42
+ /\b(?:my\s+)?reasoning\s*:/i,
43
+ /<\/?\s*(?:thinking|thought|reasoning|scratchpad)\b/i,
44
+ ]
45
+
46
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g
47
+ const WHITESPACE = /\s+/g
48
+
49
+ export function normalizeTaskPlanToolName(toolName: unknown): string | undefined {
50
+ if (typeof toolName !== 'string') return undefined
51
+ const trimmed = toolName.trim()
52
+ if (!trimmed) return undefined
53
+ const dotted = trimmed.replace(/__/g, '.')
54
+ const safe = dotted.replace(/[^a-zA-Z0-9._:-]/g, '').slice(0, TASK_PLAN_TOOL_NAME_MAX_CHARS)
55
+ return safe.length > 0 ? safe : undefined
56
+ }
57
+
58
+ export function isTaskPlanToolName(toolName: unknown): boolean {
59
+ return normalizeTaskPlanToolName(toolName) === TASK_PLAN_TOOL_NAME
60
+ }
61
+
62
+ export function looksLikeHiddenReasoning(value: string): boolean {
63
+ return HIDDEN_REASONING_PATTERNS.some((pattern) => pattern.test(value))
64
+ }
65
+
66
+ export function sanitizeTaskPlanText(
67
+ value: unknown,
68
+ maxChars: number,
69
+ ): string | null {
70
+ if (typeof value !== 'string') return null
71
+ const normalized = value.replace(CONTROL_CHARS, ' ').replace(WHITESPACE, ' ').trim()
72
+ if (!normalized) return null
73
+ if (looksLikeHiddenReasoning(normalized)) return null
74
+ return normalized.slice(0, maxChars)
75
+ }
76
+
77
+ export function sanitizeTaskPlanId(value: unknown): string | undefined {
78
+ if (typeof value !== 'string') return undefined
79
+ const normalized = value
80
+ .trim()
81
+ .replace(/\s+/g, '-')
82
+ .replace(/[^a-zA-Z0-9._:-]/g, '')
83
+ .slice(0, TASK_PLAN_ID_MAX_CHARS)
84
+ return normalized.length > 0 ? normalized : undefined
85
+ }
86
+
87
+ export function sanitizeAgentTaskPlanInput(input: unknown): SanitizedAgentTaskPlanInput {
88
+ if (!input || typeof input !== 'object') {
89
+ return { tasks: [] }
90
+ }
91
+ const rawTasks = (input as { tasks?: unknown }).tasks
92
+ if (!Array.isArray(rawTasks)) {
93
+ return { tasks: [] }
94
+ }
95
+ const tasks: SanitizedAgentTaskPlanInputTask[] = []
96
+ for (const rawTask of rawTasks.slice(0, TASK_PLAN_MAX_TASKS)) {
97
+ if (!rawTask || typeof rawTask !== 'object') continue
98
+ const value = rawTask as Record<string, unknown>
99
+ const label = sanitizeTaskPlanText(value.label, TASK_PLAN_LABEL_MAX_CHARS)
100
+ if (!label) continue
101
+ const detail = sanitizeTaskPlanText(value.detail, TASK_PLAN_DETAIL_MAX_CHARS) ?? undefined
102
+ const id = sanitizeTaskPlanId(value.id)
103
+ const toolName = normalizeTaskPlanToolName(value.toolName)
104
+ tasks.push({
105
+ ...(id ? { id } : {}),
106
+ label,
107
+ ...(detail ? { detail } : {}),
108
+ ...(toolName ? { toolName } : {}),
109
+ })
110
+ }
111
+ return { tasks }
112
+ }
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Visible AI chat agent task plan — server-side SSE injector.
3
+ *
4
+ * Spec: `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`.
5
+ *
6
+ * Wraps a streaming `Response` produced by `streamText().toUIMessageStreamResponse()`
7
+ * (or the equivalent `ToolLoopAgent.stream(...).toUIMessageStreamResponse()`)
8
+ * and interleaves additive `data-agent-task-plan` / `data-agent-task-update`
9
+ * SSE chunks alongside the AI SDK tool lifecycle chunks. The original chunks
10
+ * are passed through unchanged so existing clients that ignore unknown chunk
11
+ * types continue to work.
12
+ *
13
+ * The injector derives task labels and states from the SDK tool lifecycle:
14
+ * - `tool-input-start` → create/update task with state `running`
15
+ * - `tool-input-available` → keep task `running` (label may be refined)
16
+ * - `tool-output-available` → mark task `done`
17
+ * - `tool-output-error` → mark task `failed`
18
+ * - `tool-input-error` → mark task `failed`
19
+ *
20
+ * Agent-authored labels flow through the reserved non-mutation
21
+ * `meta.update_task_plan` tool. Its input is sanitized before the plan reaches
22
+ * the client; the raw meta-tool call is still passed through for older clients
23
+ * but the visible plan uses only the safe labels.
24
+ */
25
+
26
+ import {
27
+ TASK_PLAN_LABEL_MAX_CHARS,
28
+ isTaskPlanToolName,
29
+ normalizeTaskPlanToolName,
30
+ sanitizeAgentTaskPlanInput,
31
+ } from './task-plan-labels'
32
+
33
+ const SSE_ENCODER = new TextEncoder()
34
+ const SSE_DECODER = new TextDecoder()
35
+
36
+ /**
37
+ * Mirrors the client-side `AiAgentTaskSnapshot` so server and client agree on
38
+ * the wire format. Kept locally in this module to avoid pulling a UI package
39
+ * dependency into the runtime — the shape is small and only ever serialized
40
+ * to JSON for the SSE chunks below.
41
+ */
42
+ export interface ServerTaskSnapshot {
43
+ id: string
44
+ label: string
45
+ state: 'pending' | 'running' | 'done' | 'failed' | 'skipped'
46
+ detail?: string
47
+ source: 'runtime' | 'agent'
48
+ toolCallId?: string
49
+ }
50
+
51
+ const TERMINAL_STATES: ReadonlySet<ServerTaskSnapshot['state']> = new Set([
52
+ 'done',
53
+ 'failed',
54
+ 'skipped',
55
+ ])
56
+
57
+ const TASK_LABEL_MAX_CHARS = TASK_PLAN_LABEL_MAX_CHARS
58
+
59
+ /**
60
+ * Convert a raw model-sanitized tool name (e.g. `customers__list_people`) to a
61
+ * compact operator-facing label (e.g. `Customers list people`). The trailing
62
+ * segment is title-cased so the plan reads like a checklist instead of a code
63
+ * trace.
64
+ */
65
+ export function deriveTaskLabel(toolName: string | undefined): string {
66
+ if (typeof toolName !== 'string' || toolName.length === 0) {
67
+ return 'Tool call'
68
+ }
69
+ const display = toolName.replace(/__/g, '.')
70
+ const segments = display.split('.')
71
+ const lastSegment = segments[segments.length - 1] ?? display
72
+ const humanized = lastSegment.replace(/_/g, ' ').trim()
73
+ if (humanized.length === 0) return display.slice(0, TASK_LABEL_MAX_CHARS)
74
+ const titled = humanized.charAt(0).toUpperCase() + humanized.slice(1)
75
+ if (segments.length <= 1) {
76
+ return titled.slice(0, TASK_LABEL_MAX_CHARS)
77
+ }
78
+ const moduleSegment = segments[0]
79
+ const moduleLabel = moduleSegment.charAt(0).toUpperCase() + moduleSegment.slice(1).replace(/_/g, ' ')
80
+ const combined = `${moduleLabel} · ${titled}`
81
+ return combined.slice(0, TASK_LABEL_MAX_CHARS)
82
+ }
83
+
84
+ type AccumulatorEntry = {
85
+ snapshot: ServerTaskSnapshot
86
+ emitted: boolean
87
+ }
88
+
89
+ type ToolChunk = {
90
+ type?: unknown
91
+ toolCallId?: unknown
92
+ toolName?: unknown
93
+ input?: unknown
94
+ }
95
+
96
+ /**
97
+ * Encapsulates the per-turn task-plan state. Exposed for unit tests so the
98
+ * derivation logic can be exercised without standing up a full SSE pipeline.
99
+ */
100
+ export class TaskPlanAccumulator {
101
+ private readonly tasks = new Map<string, AccumulatorEntry>()
102
+ private readonly toolCallToTaskId = new Map<string, string>()
103
+ private readonly taskToolNames = new Map<string, string>()
104
+ private readonly internalToolCallIds = new Set<string>()
105
+ private snapshotEmitted = false
106
+ private hasAgentAuthoredPlan = false
107
+
108
+ constructor(public readonly planId: string) {}
109
+
110
+ private upsert(
111
+ id: string,
112
+ patch: Partial<ServerTaskSnapshot> & { label?: string; toolCallId?: string },
113
+ ): ServerTaskSnapshot {
114
+ const existing = this.tasks.get(id)
115
+ if (!existing) {
116
+ const created: ServerTaskSnapshot = {
117
+ id,
118
+ label: patch.label ?? 'Tool call',
119
+ state: patch.state ?? 'running',
120
+ source: patch.source ?? 'runtime',
121
+ detail: patch.detail,
122
+ toolCallId: patch.toolCallId,
123
+ }
124
+ this.tasks.set(id, { snapshot: created, emitted: false })
125
+ return created
126
+ }
127
+ const current = existing.snapshot
128
+ const nextState = TERMINAL_STATES.has(current.state) ? current.state : patch.state ?? current.state
129
+ const merged: ServerTaskSnapshot = {
130
+ id: current.id,
131
+ label: patch.label ?? current.label,
132
+ state: nextState,
133
+ source: patch.source ?? current.source,
134
+ detail: patch.detail ?? current.detail,
135
+ toolCallId: patch.toolCallId ?? current.toolCallId,
136
+ }
137
+ this.tasks.set(id, { snapshot: merged, emitted: existing.emitted })
138
+ return merged
139
+ }
140
+
141
+ private makeUniqueTaskId(baseId: string): string {
142
+ let candidate = baseId
143
+ let suffix = 2
144
+ while (this.tasks.has(candidate)) {
145
+ candidate = `${baseId}-${suffix}`
146
+ suffix += 1
147
+ }
148
+ return candidate
149
+ }
150
+
151
+ private emitFullSnapshot(): string[] {
152
+ if (this.tasks.size === 0) return []
153
+ this.snapshotEmitted = true
154
+ const initialTasks = Array.from(this.tasks.values()).map((e) => e.snapshot)
155
+ for (const e of this.tasks.values()) e.emitted = true
156
+ return [
157
+ formatSseEvent({
158
+ type: 'data-agent-task-plan',
159
+ planId: this.planId,
160
+ tasks: initialTasks,
161
+ }),
162
+ ]
163
+ }
164
+
165
+ private handleAgentAuthoredPlan(input: unknown): string[] {
166
+ const plan = sanitizeAgentTaskPlanInput(input)
167
+ if (plan.tasks.length === 0) return []
168
+
169
+ this.tasks.clear()
170
+ this.toolCallToTaskId.clear()
171
+ this.taskToolNames.clear()
172
+ this.snapshotEmitted = false
173
+ this.hasAgentAuthoredPlan = true
174
+
175
+ plan.tasks.forEach((task, index) => {
176
+ const id = this.makeUniqueTaskId(task.id ?? `agent-plan-${index + 1}`)
177
+ const snapshot: ServerTaskSnapshot = {
178
+ id,
179
+ label: task.label,
180
+ state: 'pending',
181
+ source: 'agent',
182
+ detail: task.detail,
183
+ }
184
+ this.tasks.set(id, { snapshot, emitted: false })
185
+ if (task.toolName) {
186
+ this.taskToolNames.set(id, task.toolName)
187
+ }
188
+ })
189
+
190
+ return this.emitFullSnapshot()
191
+ }
192
+
193
+ private resolveTaskIdForToolCall(toolCallId: string, toolName: string | undefined): string {
194
+ const existing = this.toolCallToTaskId.get(toolCallId)
195
+ if (existing) return existing
196
+ const plannedTaskId = this.findPlannedTaskId(toolName)
197
+ if (plannedTaskId) {
198
+ this.toolCallToTaskId.set(toolCallId, plannedTaskId)
199
+ return plannedTaskId
200
+ }
201
+ this.toolCallToTaskId.set(toolCallId, toolCallId)
202
+ return toolCallId
203
+ }
204
+
205
+ private findPlannedTaskId(toolName: string | undefined): string | null {
206
+ if (!this.hasAgentAuthoredPlan) return null
207
+ const entries = Array.from(this.tasks.entries())
208
+ const isAvailable = (entry: AccumulatorEntry) => !TERMINAL_STATES.has(entry.snapshot.state)
209
+ if (toolName) {
210
+ const exactPending = entries.find(([id, entry]) => {
211
+ return entry.snapshot.state === 'pending' && this.taskToolNames.get(id) === toolName
212
+ })
213
+ if (exactPending) return exactPending[0]
214
+ const exactAvailable = entries.find(([id, entry]) => {
215
+ return isAvailable(entry) && this.taskToolNames.get(id) === toolName
216
+ })
217
+ if (exactAvailable) return exactAvailable[0]
218
+ }
219
+ const genericPending = entries.find(([id, entry]) => {
220
+ return entry.snapshot.state === 'pending' && !this.taskToolNames.has(id)
221
+ })
222
+ if (genericPending) return genericPending[0]
223
+ const genericAvailable = entries.find(([id, entry]) => {
224
+ return isAvailable(entry) && !this.taskToolNames.has(id)
225
+ })
226
+ return genericAvailable?.[0] ?? null
227
+ }
228
+
229
+ private existingSnapshot(taskId: string): ServerTaskSnapshot | undefined {
230
+ return this.tasks.get(taskId)?.snapshot
231
+ }
232
+
233
+ /**
234
+ * Apply a tool lifecycle chunk. Returns the SSE event lines (already
235
+ * `data: ...\n\n`-formatted) that should be injected ahead of forwarding
236
+ * the original chunk to the client.
237
+ */
238
+ handleToolChunk(chunk: ToolChunk): string[] {
239
+ if (!chunk || typeof chunk.type !== 'string') return []
240
+ const toolCallId = typeof chunk.toolCallId === 'string' ? chunk.toolCallId : null
241
+ const toolName = normalizeTaskPlanToolName(chunk.toolName)
242
+ if (isTaskPlanToolName(toolName)) {
243
+ if (toolCallId) this.internalToolCallIds.add(toolCallId)
244
+ if (chunk.type === 'tool-input-available') {
245
+ return this.handleAgentAuthoredPlan(chunk.input)
246
+ }
247
+ return []
248
+ }
249
+ if (!toolCallId) return []
250
+ if (this.internalToolCallIds.has(toolCallId)) return []
251
+ const taskId = this.resolveTaskIdForToolCall(toolCallId, toolName)
252
+ const existing = this.existingSnapshot(taskId)
253
+ const source = existing?.source ?? 'runtime'
254
+ const runtimeLabel = deriveTaskLabel(toolName)
255
+ const runtimeDetail = toolName
256
+ let nextSnapshot: ServerTaskSnapshot | null = null
257
+ switch (chunk.type) {
258
+ case 'tool-input-start':
259
+ nextSnapshot = this.upsert(taskId, {
260
+ label: source === 'agent' ? existing?.label : runtimeLabel,
261
+ state: 'running',
262
+ source,
263
+ toolCallId,
264
+ detail: source === 'agent' ? existing?.detail : runtimeDetail,
265
+ })
266
+ break
267
+ case 'tool-input-available':
268
+ // Runtime-derived tasks can refine the label when the SDK includes a
269
+ // richer toolName on input-available. Agent-authored tasks keep the
270
+ // safe label the model supplied through `meta.update_task_plan`.
271
+ nextSnapshot = this.upsert(taskId, {
272
+ label: source === 'agent' ? existing?.label : runtimeLabel,
273
+ state: 'running',
274
+ source,
275
+ toolCallId,
276
+ detail: source === 'agent' ? existing?.detail : runtimeDetail,
277
+ })
278
+ break
279
+ case 'tool-output-available':
280
+ nextSnapshot = this.upsert(taskId, {
281
+ state: 'done',
282
+ source,
283
+ toolCallId,
284
+ })
285
+ break
286
+ case 'tool-output-error':
287
+ case 'tool-input-error':
288
+ nextSnapshot = this.upsert(taskId, {
289
+ state: 'failed',
290
+ source,
291
+ toolCallId,
292
+ })
293
+ break
294
+ default:
295
+ return []
296
+ }
297
+ if (!nextSnapshot) return []
298
+ return this.emitForSnapshot(taskId, nextSnapshot)
299
+ }
300
+
301
+ private emitForSnapshot(id: string, snapshot: ServerTaskSnapshot): string[] {
302
+ const entry = this.tasks.get(id)
303
+ if (!entry) return []
304
+ const lines: string[] = []
305
+ if (!this.snapshotEmitted) {
306
+ return this.emitFullSnapshot()
307
+ }
308
+ if (!entry.emitted) {
309
+ // First time we surface this task: it must be part of the next snapshot
310
+ // refresh, but to keep the protocol minimal we emit a single
311
+ // `data-agent-task-update` carrying the new task — clients merge by id.
312
+ lines.push(
313
+ formatSseEvent({
314
+ type: 'data-agent-task-update',
315
+ planId: this.planId,
316
+ task: snapshot,
317
+ }),
318
+ )
319
+ entry.emitted = true
320
+ return lines
321
+ }
322
+ lines.push(
323
+ formatSseEvent({
324
+ type: 'data-agent-task-update',
325
+ planId: this.planId,
326
+ task: snapshot,
327
+ }),
328
+ )
329
+ return lines
330
+ }
331
+ }
332
+
333
+ export function formatSseEvent(payload: Record<string, unknown>): string {
334
+ return `data: ${JSON.stringify(payload)}\n\n`
335
+ }
336
+
337
+ /**
338
+ * Wrap a streaming `Response` and interleave `data-agent-task-plan` /
339
+ * `data-agent-task-update` SSE chunks. The wrapper does not consume the
340
+ * stream — it pipes bytes through and only parses event boundaries to know
341
+ * when to inject extra chunks.
342
+ */
343
+ export function injectTaskPlanIntoStream(
344
+ baseResponse: Response,
345
+ planId: string,
346
+ ): Response {
347
+ const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
348
+ const writer = writable.getWriter()
349
+ const accumulator = new TaskPlanAccumulator(planId)
350
+
351
+ async function pump(): Promise<void> {
352
+ if (!baseResponse.body) {
353
+ await writer.close()
354
+ return
355
+ }
356
+ const reader = baseResponse.body.getReader()
357
+ let textBuffer = ''
358
+ try {
359
+ for (;;) {
360
+ const { value, done } = await reader.read()
361
+ if (done) break
362
+ if (!value) continue
363
+ textBuffer += SSE_DECODER.decode(value, { stream: true })
364
+ textBuffer = await flushBuffer(textBuffer, accumulator, writer)
365
+ }
366
+ const tail = SSE_DECODER.decode()
367
+ if (tail) {
368
+ textBuffer += tail
369
+ }
370
+ if (textBuffer.length > 0) {
371
+ // Best-effort flush of any trailing bytes (the AI SDK always
372
+ // terminates events with `\n\n` so this path is rare).
373
+ await writer.write(SSE_ENCODER.encode(textBuffer))
374
+ }
375
+ } catch {
376
+ // Surface upstream aborts to the downstream consumer by closing the
377
+ // writer — propagating the error would corrupt the SSE stream.
378
+ } finally {
379
+ reader.releaseLock()
380
+ await writer.close().catch(() => undefined)
381
+ }
382
+ }
383
+
384
+ void pump()
385
+ return new Response(readable, {
386
+ status: baseResponse.status,
387
+ headers: baseResponse.headers,
388
+ })
389
+ }
390
+
391
+ async function flushBuffer(
392
+ buffer: string,
393
+ accumulator: TaskPlanAccumulator,
394
+ writer: WritableStreamDefaultWriter<Uint8Array>,
395
+ ): Promise<string> {
396
+ let rest = buffer
397
+ for (;;) {
398
+ const boundary = rest.indexOf('\n\n')
399
+ if (boundary === -1) break
400
+ const eventBlock = rest.slice(0, boundary + 2)
401
+ rest = rest.slice(boundary + 2)
402
+ const injected = inspectEventBlock(eventBlock, accumulator)
403
+ for (const line of injected.before) {
404
+ await writer.write(SSE_ENCODER.encode(line))
405
+ }
406
+ await writer.write(SSE_ENCODER.encode(eventBlock))
407
+ for (const line of injected.after) {
408
+ await writer.write(SSE_ENCODER.encode(line))
409
+ }
410
+ }
411
+ return rest
412
+ }
413
+
414
+ interface InjectedLines {
415
+ before: string[]
416
+ after: string[]
417
+ }
418
+
419
+ function inspectEventBlock(
420
+ eventBlock: string,
421
+ accumulator: TaskPlanAccumulator,
422
+ ): InjectedLines {
423
+ const dataPayload = extractDataPayload(eventBlock)
424
+ if (!dataPayload || dataPayload === '[DONE]') {
425
+ return { before: [], after: [] }
426
+ }
427
+ let parsed: ToolChunk | null = null
428
+ try {
429
+ parsed = JSON.parse(dataPayload)
430
+ } catch {
431
+ return { before: [], after: [] }
432
+ }
433
+ if (!parsed || typeof parsed.type !== 'string') {
434
+ return { before: [], after: [] }
435
+ }
436
+ const type = parsed.type
437
+ const injected = accumulator.handleToolChunk(parsed)
438
+ if (injected.length === 0) {
439
+ return { before: [], after: [] }
440
+ }
441
+ // Tool-input-start gets the plan event BEFORE the original (so the row
442
+ // appears at the same time as the tool starts). Output / error events
443
+ // get the plan event AFTER so the row updates only once the tool result
444
+ // is visible in the existing tool-call detail row.
445
+ if (type === 'tool-input-start' || type === 'tool-input-available') {
446
+ return { before: injected, after: [] }
447
+ }
448
+ return { before: [], after: injected }
449
+ }
450
+
451
+ function extractDataPayload(eventBlock: string): string | null {
452
+ const lines = eventBlock.split('\n')
453
+ const dataLines: string[] = []
454
+ for (const line of lines) {
455
+ if (line.startsWith('data: ')) {
456
+ dataLines.push(line.slice(6))
457
+ } else if (line.startsWith('data:')) {
458
+ dataLines.push(line.slice(5))
459
+ }
460
+ }
461
+ if (dataLines.length === 0) return null
462
+ return dataLines.join('\n')
463
+ }
@@ -44,6 +44,9 @@ export const toolFixtures: Record<string, ToolFixture> = {
44
44
  'meta.describe_agent': f({
45
45
  input: { agentId: 'customers.account_assistant' },
46
46
  }),
47
+ 'meta.update_task_plan': f({
48
+ input: { tasks: [{ label: 'Search records', toolName: 'customers.list_people' }] },
49
+ }),
47
50
  'attachments.list_record_attachments': f({
48
51
  input: { entityType: 'customers:person', recordId: '00000000-0000-0000-0000-000000000000', limit: 5 },
49
52
  note: 'Empty result is a valid response; we only assert shape.',
@@ -54,6 +54,8 @@ export interface AiToolLoadBeforeRecord {
54
54
  label: string
55
55
  recordVersion: string | null
56
56
  before: Record<string, unknown>
57
+ after?: Record<string, unknown>
58
+ display?: AiToolFieldDiffDisplayHints
57
59
  }
58
60
 
59
61
  /**
@@ -67,6 +69,20 @@ export interface AiToolLoadBeforeSingleRecord {
67
69
  entityType: string
68
70
  recordVersion: string | null
69
71
  before: Record<string, unknown>
72
+ after?: Record<string, unknown>
73
+ display?: AiToolFieldDiffDisplayHints
74
+ }
75
+
76
+ /**
77
+ * Optional display hints for mutation-preview diffs. Raw `before` / `after`
78
+ * values remain persisted for execution and stale checks; these labels are
79
+ * only for operator-facing cards, e.g. showing a pipeline stage name instead
80
+ * of its UUID.
81
+ */
82
+ export interface AiToolFieldDiffDisplayHints {
83
+ fieldLabels?: Record<string, string>
84
+ before?: Record<string, unknown>
85
+ after?: Record<string, unknown>
70
86
  }
71
87
 
72
88
  /**