@roj-ai/sdk 0.1.14 → 0.1.16

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 (109) hide show
  1. package/dist/bootstrap.d.ts +1 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/core/agents/agent.d.ts +25 -1
  4. package/dist/core/agents/agent.d.ts.map +1 -1
  5. package/dist/core/agents/agent.js +117 -21
  6. package/dist/core/agents/agent.js.map +1 -1
  7. package/dist/core/agents/config.d.ts +7 -0
  8. package/dist/core/agents/config.d.ts.map +1 -1
  9. package/dist/core/agents/context.d.ts +10 -0
  10. package/dist/core/agents/context.d.ts.map +1 -1
  11. package/dist/core/agents/state.d.ts +11 -3
  12. package/dist/core/agents/state.d.ts.map +1 -1
  13. package/dist/core/agents/state.js.map +1 -1
  14. package/dist/core/file-store/file-store.d.ts +5 -1
  15. package/dist/core/file-store/file-store.d.ts.map +1 -1
  16. package/dist/core/file-store/file-store.js +31 -21
  17. package/dist/core/file-store/file-store.js.map +1 -1
  18. package/dist/core/image/vips-resizer.test.js +26 -14
  19. package/dist/core/image/vips-resizer.test.js.map +1 -1
  20. package/dist/core/llm/anthropic.d.ts.map +1 -1
  21. package/dist/core/llm/anthropic.js +11 -8
  22. package/dist/core/llm/anthropic.js.map +1 -1
  23. package/dist/core/llm/cache-breakpoints.d.ts +5 -1
  24. package/dist/core/llm/cache-breakpoints.d.ts.map +1 -1
  25. package/dist/core/llm/cache-breakpoints.js +10 -5
  26. package/dist/core/llm/cache-breakpoints.js.map +1 -1
  27. package/dist/core/sessions/session.d.ts.map +1 -1
  28. package/dist/core/sessions/session.js +3 -0
  29. package/dist/core/sessions/session.js.map +1 -1
  30. package/dist/core/sessions/session.test.js +5 -0
  31. package/dist/core/sessions/session.test.js.map +1 -1
  32. package/dist/core/sessions/state.d.ts.map +1 -1
  33. package/dist/core/sessions/state.js +5 -1
  34. package/dist/core/sessions/state.js.map +1 -1
  35. package/dist/core/tools/executor.test.js +1 -0
  36. package/dist/core/tools/executor.test.js.map +1 -1
  37. package/dist/plugins/agent-status/plugin.d.ts.map +1 -1
  38. package/dist/plugins/agent-status/plugin.js +18 -26
  39. package/dist/plugins/agent-status/plugin.js.map +1 -1
  40. package/dist/plugins/context-compact/compaction-live.test.d.ts +17 -0
  41. package/dist/plugins/context-compact/compaction-live.test.d.ts.map +1 -0
  42. package/dist/plugins/context-compact/compaction-live.test.js +177 -0
  43. package/dist/plugins/context-compact/compaction-live.test.js.map +1 -0
  44. package/dist/plugins/context-compact/context-compact.integration.test.js +123 -3
  45. package/dist/plugins/context-compact/context-compact.integration.test.js.map +1 -1
  46. package/dist/plugins/context-compact/context-compactor.d.ts +47 -17
  47. package/dist/plugins/context-compact/context-compactor.d.ts.map +1 -1
  48. package/dist/plugins/context-compact/context-compactor.js +60 -36
  49. package/dist/plugins/context-compact/context-compactor.js.map +1 -1
  50. package/dist/plugins/context-compact/context-compactor.test.js +69 -103
  51. package/dist/plugins/context-compact/context-compactor.test.js.map +1 -1
  52. package/dist/plugins/context-compact/plugin.d.ts +9 -2
  53. package/dist/plugins/context-compact/plugin.d.ts.map +1 -1
  54. package/dist/plugins/context-compact/plugin.js +8 -4
  55. package/dist/plugins/context-compact/plugin.js.map +1 -1
  56. package/dist/plugins/filesystem/filesystem.integration.test.js +36 -0
  57. package/dist/plugins/filesystem/filesystem.integration.test.js.map +1 -1
  58. package/dist/plugins/filesystem/plugin.d.ts.map +1 -1
  59. package/dist/plugins/filesystem/plugin.js +8 -6
  60. package/dist/plugins/filesystem/plugin.js.map +1 -1
  61. package/dist/plugins/mailbox/mailbox.integration.test.js +9 -16
  62. package/dist/plugins/mailbox/mailbox.integration.test.js.map +1 -1
  63. package/dist/plugins/resources/plugin.d.ts.map +1 -1
  64. package/dist/plugins/resources/plugin.js +4 -1
  65. package/dist/plugins/resources/plugin.js.map +1 -1
  66. package/dist/plugins/uploads/preprocessors/image-classifier.d.ts.map +1 -1
  67. package/dist/plugins/uploads/preprocessors/image-classifier.js +15 -2
  68. package/dist/plugins/uploads/preprocessors/image-classifier.js.map +1 -1
  69. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.d.ts.map +1 -1
  70. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js +72 -19
  71. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js.map +1 -1
  72. package/dist/plugins/user-chat/plugin.d.ts +2 -0
  73. package/dist/plugins/user-chat/plugin.d.ts.map +1 -1
  74. package/dist/plugins/user-chat/plugin.js +47 -3
  75. package/dist/plugins/user-chat/plugin.js.map +1 -1
  76. package/dist/plugins/user-chat/schema.d.ts +10 -0
  77. package/dist/plugins/user-chat/schema.d.ts.map +1 -1
  78. package/dist/plugins/user-chat/schema.js +1 -0
  79. package/dist/plugins/user-chat/schema.js.map +1 -1
  80. package/dist/plugins/user-chat/user-chat.integration.test.js +86 -0
  81. package/dist/plugins/user-chat/user-chat.integration.test.js.map +1 -1
  82. package/package.json +2 -2
  83. package/src/core/agents/agent.ts +134 -20
  84. package/src/core/agents/config.ts +7 -0
  85. package/src/core/agents/context.ts +11 -0
  86. package/src/core/agents/state.ts +11 -4
  87. package/src/core/file-store/file-store.ts +38 -18
  88. package/src/core/image/vips-resizer.test.ts +26 -15
  89. package/src/core/llm/anthropic.ts +19 -12
  90. package/src/core/llm/cache-breakpoints.ts +15 -6
  91. package/src/core/sessions/session.test.ts +6 -0
  92. package/src/core/sessions/session.ts +4 -0
  93. package/src/core/sessions/state.ts +5 -1
  94. package/src/core/tools/executor.test.ts +1 -0
  95. package/src/plugins/agent-status/plugin.ts +18 -25
  96. package/src/plugins/context-compact/compaction-live.test.ts +221 -0
  97. package/src/plugins/context-compact/context-compact.integration.test.ts +135 -3
  98. package/src/plugins/context-compact/context-compactor.test.ts +71 -110
  99. package/src/plugins/context-compact/context-compactor.ts +88 -43
  100. package/src/plugins/context-compact/plugin.ts +19 -10
  101. package/src/plugins/filesystem/filesystem.integration.test.ts +44 -0
  102. package/src/plugins/filesystem/plugin.ts +8 -6
  103. package/src/plugins/mailbox/mailbox.integration.test.ts +12 -18
  104. package/src/plugins/resources/plugin.ts +4 -1
  105. package/src/plugins/uploads/preprocessors/image-classifier.ts +15 -2
  106. package/src/plugins/uploads/preprocessors/markitdown-preprocessor.ts +89 -20
  107. package/src/plugins/user-chat/plugin.ts +60 -3
  108. package/src/plugins/user-chat/schema.ts +10 -1
  109. package/src/plugins/user-chat/user-chat.integration.test.ts +99 -0
@@ -25,8 +25,16 @@ import type { Preprocessor, PreprocessorContext, PreprocessorRegistry, Preproces
25
25
  const MAX_IMAGES = 50
26
26
  const IMAGE_CLASSIFY_CONCURRENCY = 10
27
27
 
28
+ // markitdown converts a text-only document; even large PDFs finish in seconds.
29
+ const MARKITDOWN_TIMEOUT_MS = 60_000
30
+ // Image extractors (pdfimages, pandoc --extract-media) scale with image count
31
+ // and resolution. Real-world large brand PDFs (40 pages, 5MB images) can take
32
+ // 60–90s. Upload preprocessing is async/background, so allow generous headroom.
33
+ const IMAGE_EXTRACT_TIMEOUT_MS = 5 * 60_000
34
+
28
35
  function makeExec(processRunner: ProcessRunner) {
29
- return (cmd: string, args: string[]) => processRunner.execFile(cmd, args, { timeout: 60_000, maxBuffer: 50 * 1024 * 1024 })
36
+ return (cmd: string, args: string[], timeoutMs: number = MARKITDOWN_TIMEOUT_MS) =>
37
+ processRunner.execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 50 * 1024 * 1024 })
30
38
  }
31
39
 
32
40
  /** MIME types where markitdown converts to markdown (non-ZIP, non-image) */
@@ -78,7 +86,7 @@ export class MarkitdownPreprocessor implements Preprocessor {
78
86
  private readonly registry: PreprocessorRegistry
79
87
  private readonly logger: Logger
80
88
  private readonly fs: FileSystem
81
- private readonly exec: (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string }>
89
+ private readonly exec: (cmd: string, args: string[], timeoutMs?: number) => Promise<{ stdout: string; stderr: string }>
82
90
 
83
91
  constructor(config: MarkitdownPreprocessorConfig) {
84
92
  this.registry = config.registry
@@ -92,20 +100,29 @@ export class MarkitdownPreprocessor implements Preprocessor {
92
100
  mimeType: string,
93
101
  ctx: PreprocessorContext,
94
102
  ): Promise<Result<PreprocessorResult, Error>> {
103
+ const totalStart = Date.now()
95
104
  const derivedPaths: string[] = []
96
105
  const imageEntries: string[] = []
97
106
 
107
+ this.logger.info('Markitdown processing started', { filePath, mimeType })
108
+
98
109
  // 1. Convert to markdown via markitdown
99
110
  const contentPathResult = ctx.files.realPath('content.md')
100
111
  if (!contentPathResult.ok) {
101
112
  return Err(new Error('Failed to resolve output path'))
102
113
  }
103
114
 
115
+ const markitdownStart = Date.now()
104
116
  try {
105
117
  await this.fs.mkdir(dirname(contentPathResult.value), { recursive: true })
106
118
  await this.exec('markitdown', [filePath, '-o', contentPathResult.value])
107
119
  } catch (error) {
108
120
  const message = error instanceof Error ? error.message : String(error)
121
+ this.logger.error(
122
+ 'markitdown CLI failed',
123
+ error instanceof Error ? error : undefined,
124
+ { filePath, mimeType, durationMs: Date.now() - markitdownStart },
125
+ )
109
126
  if (message.includes('ENOENT')) {
110
127
  return Err(new Error('markitdown not found. Install with: pip install "markitdown[all]"'))
111
128
  }
@@ -117,7 +134,15 @@ export class MarkitdownPreprocessor implements Preprocessor {
117
134
 
118
135
  derivedPaths.push('content.md')
119
136
 
137
+ this.logger.info('Markitdown conversion complete', {
138
+ filePath,
139
+ mimeType,
140
+ durationMs: Date.now() - markitdownStart,
141
+ contentLength: markdown.length,
142
+ })
143
+
120
144
  // 2. Extract images based on file type
145
+ const imagePhaseStart = Date.now()
121
146
  if (PANDOC_EXTRACT_MIMES.has(mimeType)) {
122
147
  const images = await this.extractImagesWithPandoc(filePath, mimeType, ctx)
123
148
  for (const img of images) {
@@ -131,17 +156,20 @@ export class MarkitdownPreprocessor implements Preprocessor {
131
156
  imageEntries.push(`- ${img.relativePath} — ${img.description}`)
132
157
  }
133
158
  }
159
+ const imagePhaseDurationMs = Date.now() - imagePhaseStart
134
160
 
135
161
  // 3. Build manifest
136
162
  const manifestLines: string[] = ['Extracted files:']
137
163
  manifestLines.push(`- content.md (markdown, ${markdown.length} chars)`)
138
164
  manifestLines.push(...imageEntries)
139
165
 
140
- this.logger.debug('Markitdown processed', {
166
+ this.logger.info('Markitdown processing complete', {
141
167
  filePath,
142
168
  mimeType,
143
169
  contentLength: markdown.length,
144
170
  imagesExtracted: imageEntries.length,
171
+ imagePhaseDurationMs,
172
+ totalDurationMs: Date.now() - totalStart,
145
173
  })
146
174
 
147
175
  return Ok({
@@ -162,23 +190,39 @@ export class MarkitdownPreprocessor implements Preprocessor {
162
190
  const format = PANDOC_FORMAT_MAP[mimeType]
163
191
  if (!format) return []
164
192
 
193
+ const pandocStart = Date.now()
194
+ let extractSucceeded = true
165
195
  try {
166
- await this.exec('pandoc', [
167
- '-f',
168
- format,
169
- '-t',
170
- 'gfm',
196
+ await this.exec(
197
+ 'pandoc',
198
+ ['-f', format, '-t', 'gfm', filePath, '-o', '/dev/null', `--extract-media=${mediaDirResult.value}`],
199
+ IMAGE_EXTRACT_TIMEOUT_MS,
200
+ )
201
+ } catch (error) {
202
+ extractSucceeded = false
203
+ this.logger.warn('pandoc --extract-media failed (will classify any partial output)', {
204
+ filePath,
205
+ durationMs: Date.now() - pandocStart,
206
+ error: error instanceof Error ? error.message : String(error),
207
+ })
208
+ }
209
+ if (extractSucceeded) {
210
+ this.logger.info('pandoc --extract-media complete', {
171
211
  filePath,
172
- '-o',
173
- '/dev/null',
174
- `--extract-media=${mediaDirResult.value}`,
175
- ])
176
- } catch {
177
- this.logger.warn('pandoc --extract-media failed', { filePath })
178
- return []
212
+ format,
213
+ durationMs: Date.now() - pandocStart,
214
+ })
179
215
  }
180
216
 
181
- return classifyExtractedImages(mediaStore, 'media', ctx, this.registry, this.logger)
217
+ const classifyStart = Date.now()
218
+ const images = await classifyExtractedImages(mediaStore, 'media', ctx, this.registry, this.logger)
219
+ this.logger.info('Image classification complete', {
220
+ source: 'pandoc',
221
+ count: images.length,
222
+ partial: !extractSucceeded,
223
+ durationMs: Date.now() - classifyStart,
224
+ })
225
+ return images
182
226
  }
183
227
 
184
228
  private async extractImagesWithPdfimages(
@@ -189,14 +233,39 @@ export class MarkitdownPreprocessor implements Preprocessor {
189
233
  const imagesDirResult = imageStore.realPath('')
190
234
  if (!imagesDirResult.ok) return []
191
235
 
236
+ const pdfimagesStart = Date.now()
237
+ let extractSucceeded = true
192
238
  try {
193
239
  await this.fs.mkdir(imagesDirResult.value, { recursive: true })
194
- await this.exec('pdfimages', ['-png', filePath, `${imagesDirResult.value}/img`])
195
- } catch {
196
- return []
240
+ await this.exec(
241
+ 'pdfimages',
242
+ ['-png', filePath, `${imagesDirResult.value}/img`],
243
+ IMAGE_EXTRACT_TIMEOUT_MS,
244
+ )
245
+ } catch (error) {
246
+ extractSucceeded = false
247
+ this.logger.warn('pdfimages failed (will classify any partial output)', {
248
+ filePath,
249
+ durationMs: Date.now() - pdfimagesStart,
250
+ error: error instanceof Error ? error.message : String(error),
251
+ })
252
+ }
253
+ if (extractSucceeded) {
254
+ this.logger.info('pdfimages complete', {
255
+ filePath,
256
+ durationMs: Date.now() - pdfimagesStart,
257
+ })
197
258
  }
198
259
 
199
- return classifyExtractedImages(imageStore, 'images', ctx, this.registry, this.logger)
260
+ const classifyStart = Date.now()
261
+ const images = await classifyExtractedImages(imageStore, 'images', ctx, this.registry, this.logger)
262
+ this.logger.info('Image classification complete', {
263
+ source: 'pdfimages',
264
+ count: images.length,
265
+ partial: !extractSucceeded,
266
+ durationMs: Date.now() - classifyStart,
267
+ })
268
+ return images
200
269
  }
201
270
  }
202
271
 
@@ -208,6 +208,12 @@ const askUserFlatInputSchema = z.object({
208
208
  .optional()
209
209
  .describe("Placeholder text for text input"),
210
210
  multiline: z.boolean().optional().describe("Allow multiline text input"),
211
+ allowAttachments: z
212
+ .boolean()
213
+ .optional()
214
+ .describe(
215
+ "Render a file-attach control alongside the text field (text inputType only). Use when the answer needs to come with a file the user already has on hand — logo, brand PDF, source docs, supporting screenshots. Uploaded files reach you the same way as drag-drop attachments in plain chat.",
216
+ ),
211
217
  // rating options
212
218
  min: z
213
219
  .number()
@@ -248,6 +254,7 @@ function transformToAskUserInputType(
248
254
  type: "text",
249
255
  placeholder: input.placeholder,
250
256
  multiline: input.multiline,
257
+ allowAttachments: input.allowAttachments,
251
258
  };
252
259
  case "confirm":
253
260
  return {
@@ -303,6 +310,56 @@ function formatPendingForLLM(pending: PendingInboundMessage[]): string {
303
310
  return parts.join("\n");
304
311
  }
305
312
 
313
+ // Some models (notably routed through OpenRouter — Gemini/Llama/Qwen) emit
314
+ // non-ASCII tool argument strings as double-escaped JSON: the wire form is
315
+ // `"\\u0159"`, which JSON.parse turns into a literal 6-char `ř` instead
316
+ // of `ř`. The user then sees raw escape sequences in their UI. Applied only
317
+ // to user-facing display fields (question, placeholder, labels, message body)
318
+ // — never to identifiers like `option.value` (must round-trip back to the LLM
319
+ // unchanged) or to code-bearing inputs of other tools.
320
+ function decodeUnicodeEscapes(value: string): string;
321
+ function decodeUnicodeEscapes(value: string | undefined): string | undefined;
322
+ function decodeUnicodeEscapes(value: string | undefined): string | undefined {
323
+ if (value === undefined) return undefined;
324
+ if (!value.includes("\\u")) return value;
325
+ return value.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) =>
326
+ String.fromCharCode(parseInt(hex, 16)),
327
+ );
328
+ }
329
+
330
+ function decodeAskUserDisplayStrings(input: AskUserInputType): AskUserInputType {
331
+ switch (input.type) {
332
+ case "text":
333
+ return { ...input, placeholder: decodeUnicodeEscapes(input.placeholder) };
334
+ case "single_choice":
335
+ case "multi_choice":
336
+ return {
337
+ ...input,
338
+ options: input.options.map((o) => ({
339
+ ...o,
340
+ label: decodeUnicodeEscapes(o.label),
341
+ description: decodeUnicodeEscapes(o.description),
342
+ })),
343
+ };
344
+ case "confirm":
345
+ return {
346
+ ...input,
347
+ confirmLabel: decodeUnicodeEscapes(input.confirmLabel),
348
+ cancelLabel: decodeUnicodeEscapes(input.cancelLabel),
349
+ };
350
+ case "rating":
351
+ return input.labels
352
+ ? {
353
+ ...input,
354
+ labels: {
355
+ min: decodeUnicodeEscapes(input.labels.min),
356
+ max: decodeUnicodeEscapes(input.labels.max),
357
+ },
358
+ }
359
+ : input;
360
+ }
361
+ }
362
+
306
363
  // ============================================================================
307
364
  // Plugin
308
365
  // ============================================================================
@@ -687,7 +744,7 @@ export const userChatPlugin = definePlugin("user-chat")
687
744
  const format = input.format ?? "text";
688
745
  const result = await ctx.self.tellUser({
689
746
  agentId: context.agentId,
690
- message: input.message,
747
+ message: decodeUnicodeEscapes(input.message),
691
748
  format,
692
749
  });
693
750
  if (!result.ok)
@@ -710,8 +767,8 @@ export const userChatPlugin = definePlugin("user-chat")
710
767
  const inputType = transformToAskUserInputType(input);
711
768
  const result = await ctx.self.askQuestion({
712
769
  agentId: context.agentId,
713
- question: input.question,
714
- inputType,
770
+ question: decodeUnicodeEscapes(input.question),
771
+ inputType: decodeAskUserDisplayStrings(inputType),
715
772
  });
716
773
  if (!result.ok)
717
774
  return Err({ message: result.error.message, recoverable: false });
@@ -19,9 +19,17 @@ export type AskUserOption = {
19
19
 
20
20
  /**
21
21
  * Input type for ask_user tool - defines how the user should respond.
22
+ *
23
+ * `text.allowAttachments` opts the question into an inline file-attach control
24
+ * next to the text field — used when the agent needs the answer to come with a
25
+ * file (logo, brand PDF, supporting docs). Attachments piggy-back on the
26
+ * existing pending-attachments machinery: clients reuse `uploadFile()` /
27
+ * `pendingAttachments`, so the answer payload itself stays a plain string and
28
+ * the agent sees uploaded files via the normal `<attachment>` blocks in its
29
+ * inbox alongside the question answer.
22
30
  */
23
31
  export type AskUserInputType =
24
- | { type: 'text'; placeholder?: string; multiline?: boolean }
32
+ | { type: 'text'; placeholder?: string; multiline?: boolean; allowAttachments?: boolean }
25
33
  | { type: 'single_choice'; options: AskUserOption[] }
26
34
  | {
27
35
  type: 'multi_choice'
@@ -47,6 +55,7 @@ export const askUserInputTypeSchema = z.discriminatedUnion('type', [
47
55
  type: z.literal('text'),
48
56
  placeholder: z.string().optional(),
49
57
  multiline: z.boolean().optional(),
58
+ allowAttachments: z.boolean().optional(),
50
59
  }),
51
60
  z.object({
52
61
  type: z.literal('single_choice'),
@@ -564,6 +564,105 @@ describe('user-chat plugin', () => {
564
564
  })
565
565
  })
566
566
 
567
+ // =========================================================================
568
+ // Unicode escape decoding (defensive fix for models that double-escape
569
+ // non-ASCII in tool argument JSON — see plugin.ts decodeUnicodeEscapes).
570
+ // =========================================================================
571
+
572
+ describe('unicode escape decoding in user-facing fields', () => {
573
+ it('ask_user (text) → literal \\uXXXX in question/placeholder is decoded', async () => {
574
+ const harness = new TestHarness({
575
+ presets: [createTestPreset()],
576
+ llmProvider: MockLLMProvider.withSequence([
577
+ {
578
+ toolCalls: [{
579
+ id: ToolCallId('tc1'),
580
+ name: 'ask_user',
581
+ input: {
582
+ question: 'Pro\\u010d ne?',
583
+ inputType: 'text',
584
+ placeholder: 'Nap\\u0159. ano',
585
+ },
586
+ }],
587
+ },
588
+ { content: 'Done', toolCalls: [] },
589
+ ]),
590
+ })
591
+
592
+ const session = await harness.createSession('test')
593
+ await session.sendAndWaitForIdle('Hi')
594
+
595
+ const askNotifications = harness.notifications.getByType('user-chat', 'askUser')
596
+ expect(askNotifications[0].payload).toMatchObject({
597
+ question: 'Proč ne?',
598
+ inputType: { type: 'text', placeholder: 'Např. ano' },
599
+ })
600
+
601
+ await harness.shutdown()
602
+ })
603
+
604
+ it('ask_user (single_choice) → option labels decoded, values left intact', async () => {
605
+ const harness = new TestHarness({
606
+ presets: [createTestPreset()],
607
+ llmProvider: MockLLMProvider.withSequence([
608
+ {
609
+ toolCalls: [{
610
+ id: ToolCallId('tc1'),
611
+ name: 'ask_user',
612
+ input: {
613
+ question: 'Pick',
614
+ inputType: 'single_choice',
615
+ options: [
616
+ { value: 'kun_ze_\\u0159adu', label: 'K\\u016f\\u0148 ze \\u0159adu' },
617
+ ],
618
+ },
619
+ }],
620
+ },
621
+ { content: 'Done', toolCalls: [] },
622
+ ]),
623
+ })
624
+
625
+ const session = await harness.createSession('test')
626
+ await session.sendAndWaitForIdle('Hi')
627
+
628
+ const askNotifications = harness.notifications.getByType('user-chat', 'askUser')
629
+ expect(askNotifications[0].payload).toMatchObject({
630
+ inputType: {
631
+ type: 'single_choice',
632
+ // Value preserved verbatim so answer round-trip stays consistent
633
+ // with what the LLM emitted.
634
+ options: [{ value: 'kun_ze_\\u0159adu', label: 'Kůň ze řadu' }],
635
+ },
636
+ })
637
+
638
+ await harness.shutdown()
639
+ })
640
+
641
+ it('tell_user → literal \\uXXXX in message is decoded', async () => {
642
+ const harness = new TestHarness({
643
+ presets: [createTestPreset()],
644
+ llmProvider: MockLLMProvider.withSequence([
645
+ {
646
+ toolCalls: [{
647
+ id: ToolCallId('tc1'),
648
+ name: 'tell_user',
649
+ input: { message: 'Ahoj sv\\u011bte' },
650
+ }],
651
+ },
652
+ { content: 'Done', toolCalls: [] },
653
+ ]),
654
+ })
655
+
656
+ const session = await harness.createSession('test')
657
+ await session.sendAndWaitForIdle('Hi')
658
+
659
+ const msgs = harness.notifications.getByType('user-chat', 'agentMessage')
660
+ expect(msgs[0].payload).toMatchObject({ content: 'Ahoj světe' })
661
+
662
+ await harness.shutdown()
663
+ })
664
+ })
665
+
567
666
  // =========================================================================
568
667
  // XML mode
569
668
  // =========================================================================