@roj-ai/sdk 0.1.13 → 0.1.15

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 (154) hide show
  1. package/dist/bootstrap.d.ts +13 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/bootstrap.js +3 -1
  4. package/dist/bootstrap.js.map +1 -1
  5. package/dist/config.d.ts +2 -0
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +3 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/core/agents/agent.d.ts +25 -1
  10. package/dist/core/agents/agent.d.ts.map +1 -1
  11. package/dist/core/agents/agent.js +117 -21
  12. package/dist/core/agents/agent.js.map +1 -1
  13. package/dist/core/agents/config.d.ts +7 -0
  14. package/dist/core/agents/config.d.ts.map +1 -1
  15. package/dist/core/agents/context.d.ts +10 -0
  16. package/dist/core/agents/context.d.ts.map +1 -1
  17. package/dist/core/agents/state.d.ts +11 -3
  18. package/dist/core/agents/state.d.ts.map +1 -1
  19. package/dist/core/agents/state.js.map +1 -1
  20. package/dist/core/file-store/file-store.d.ts +5 -1
  21. package/dist/core/file-store/file-store.d.ts.map +1 -1
  22. package/dist/core/file-store/file-store.js +31 -21
  23. package/dist/core/file-store/file-store.js.map +1 -1
  24. package/dist/core/image/vips-resizer.test.js +26 -14
  25. package/dist/core/image/vips-resizer.test.js.map +1 -1
  26. package/dist/core/llm/anthropic.d.ts.map +1 -1
  27. package/dist/core/llm/anthropic.js +11 -8
  28. package/dist/core/llm/anthropic.js.map +1 -1
  29. package/dist/core/llm/cache-breakpoints.d.ts +5 -1
  30. package/dist/core/llm/cache-breakpoints.d.ts.map +1 -1
  31. package/dist/core/llm/cache-breakpoints.js +10 -5
  32. package/dist/core/llm/cache-breakpoints.js.map +1 -1
  33. package/dist/core/sessions/session.d.ts.map +1 -1
  34. package/dist/core/sessions/session.js +10 -0
  35. package/dist/core/sessions/session.js.map +1 -1
  36. package/dist/core/sessions/session.test.js +5 -0
  37. package/dist/core/sessions/session.test.js.map +1 -1
  38. package/dist/core/sessions/state.d.ts.map +1 -1
  39. package/dist/core/sessions/state.js +5 -1
  40. package/dist/core/sessions/state.js.map +1 -1
  41. package/dist/core/tools/executor.test.js +1 -0
  42. package/dist/core/tools/executor.test.js.map +1 -1
  43. package/dist/lib/utils/concurrency.d.ts +25 -0
  44. package/dist/lib/utils/concurrency.d.ts.map +1 -0
  45. package/dist/lib/utils/concurrency.js +69 -0
  46. package/dist/lib/utils/concurrency.js.map +1 -0
  47. package/dist/lib/utils/concurrency.test.d.ts +2 -0
  48. package/dist/lib/utils/concurrency.test.d.ts.map +1 -0
  49. package/dist/lib/utils/concurrency.test.js +135 -0
  50. package/dist/lib/utils/concurrency.test.js.map +1 -0
  51. package/dist/plugins/agent-status/plugin.d.ts.map +1 -1
  52. package/dist/plugins/agent-status/plugin.js +18 -26
  53. package/dist/plugins/agent-status/plugin.js.map +1 -1
  54. package/dist/plugins/context-compact/compaction-live.test.d.ts +17 -0
  55. package/dist/plugins/context-compact/compaction-live.test.d.ts.map +1 -0
  56. package/dist/plugins/context-compact/compaction-live.test.js +177 -0
  57. package/dist/plugins/context-compact/compaction-live.test.js.map +1 -0
  58. package/dist/plugins/context-compact/context-compact.integration.test.js +123 -3
  59. package/dist/plugins/context-compact/context-compact.integration.test.js.map +1 -1
  60. package/dist/plugins/context-compact/context-compactor.d.ts +47 -17
  61. package/dist/plugins/context-compact/context-compactor.d.ts.map +1 -1
  62. package/dist/plugins/context-compact/context-compactor.js +60 -36
  63. package/dist/plugins/context-compact/context-compactor.js.map +1 -1
  64. package/dist/plugins/context-compact/context-compactor.test.js +69 -103
  65. package/dist/plugins/context-compact/context-compactor.test.js.map +1 -1
  66. package/dist/plugins/context-compact/plugin.d.ts +9 -2
  67. package/dist/plugins/context-compact/plugin.d.ts.map +1 -1
  68. package/dist/plugins/context-compact/plugin.js +8 -4
  69. package/dist/plugins/context-compact/plugin.js.map +1 -1
  70. package/dist/plugins/filesystem/filesystem.integration.test.js +36 -0
  71. package/dist/plugins/filesystem/filesystem.integration.test.js.map +1 -1
  72. package/dist/plugins/filesystem/plugin.d.ts.map +1 -1
  73. package/dist/plugins/filesystem/plugin.js +8 -6
  74. package/dist/plugins/filesystem/plugin.js.map +1 -1
  75. package/dist/plugins/mailbox/mailbox.integration.test.js +9 -16
  76. package/dist/plugins/mailbox/mailbox.integration.test.js.map +1 -1
  77. package/dist/plugins/resources/plugin.d.ts.map +1 -1
  78. package/dist/plugins/resources/plugin.js +4 -1
  79. package/dist/plugins/resources/plugin.js.map +1 -1
  80. package/dist/plugins/uploads/plugin.d.ts +12 -0
  81. package/dist/plugins/uploads/plugin.d.ts.map +1 -1
  82. package/dist/plugins/uploads/plugin.js +188 -44
  83. package/dist/plugins/uploads/plugin.js.map +1 -1
  84. package/dist/plugins/uploads/preprocessors/image-classifier.d.ts +9 -0
  85. package/dist/plugins/uploads/preprocessors/image-classifier.d.ts.map +1 -1
  86. package/dist/plugins/uploads/preprocessors/image-classifier.js +4 -1
  87. package/dist/plugins/uploads/preprocessors/image-classifier.js.map +1 -1
  88. package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts +2 -0
  89. package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts.map +1 -0
  90. package/dist/plugins/uploads/preprocessors/image-classifier.test.js +113 -0
  91. package/dist/plugins/uploads/preprocessors/image-classifier.test.js.map +1 -0
  92. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.d.ts.map +1 -1
  93. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js +8 -7
  94. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js.map +1 -1
  95. package/dist/plugins/uploads/preprocessors/zip-preprocessor.d.ts.map +1 -1
  96. package/dist/plugins/uploads/preprocessors/zip-preprocessor.js +35 -15
  97. package/dist/plugins/uploads/preprocessors/zip-preprocessor.js.map +1 -1
  98. package/dist/plugins/uploads/state.d.ts +1 -0
  99. package/dist/plugins/uploads/state.d.ts.map +1 -1
  100. package/dist/plugins/uploads/state.js +1 -1
  101. package/dist/plugins/uploads/state.js.map +1 -1
  102. package/dist/plugins/uploads/uploads.integration.test.js +97 -0
  103. package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
  104. package/dist/plugins/user-chat/plugin.d.ts +2 -0
  105. package/dist/plugins/user-chat/plugin.d.ts.map +1 -1
  106. package/dist/plugins/user-chat/plugin.js +47 -3
  107. package/dist/plugins/user-chat/plugin.js.map +1 -1
  108. package/dist/plugins/user-chat/schema.d.ts +10 -0
  109. package/dist/plugins/user-chat/schema.d.ts.map +1 -1
  110. package/dist/plugins/user-chat/schema.js +1 -0
  111. package/dist/plugins/user-chat/schema.js.map +1 -1
  112. package/dist/plugins/user-chat/user-chat.integration.test.js +86 -0
  113. package/dist/plugins/user-chat/user-chat.integration.test.js.map +1 -1
  114. package/dist/transport/http/routes/upload.d.ts.map +1 -1
  115. package/dist/transport/http/routes/upload.js +60 -0
  116. package/dist/transport/http/routes/upload.js.map +1 -1
  117. package/package.json +2 -2
  118. package/src/bootstrap.ts +3 -1
  119. package/src/config.ts +6 -0
  120. package/src/core/agents/agent.ts +134 -20
  121. package/src/core/agents/config.ts +7 -0
  122. package/src/core/agents/context.ts +11 -0
  123. package/src/core/agents/state.ts +11 -4
  124. package/src/core/file-store/file-store.ts +38 -18
  125. package/src/core/image/vips-resizer.test.ts +26 -15
  126. package/src/core/llm/anthropic.ts +19 -12
  127. package/src/core/llm/cache-breakpoints.ts +15 -6
  128. package/src/core/sessions/session.test.ts +6 -0
  129. package/src/core/sessions/session.ts +12 -0
  130. package/src/core/sessions/state.ts +5 -1
  131. package/src/core/tools/executor.test.ts +1 -0
  132. package/src/lib/utils/concurrency.test.ts +169 -0
  133. package/src/lib/utils/concurrency.ts +72 -0
  134. package/src/plugins/agent-status/plugin.ts +18 -25
  135. package/src/plugins/context-compact/compaction-live.test.ts +221 -0
  136. package/src/plugins/context-compact/context-compact.integration.test.ts +135 -3
  137. package/src/plugins/context-compact/context-compactor.test.ts +71 -110
  138. package/src/plugins/context-compact/context-compactor.ts +88 -43
  139. package/src/plugins/context-compact/plugin.ts +19 -10
  140. package/src/plugins/filesystem/filesystem.integration.test.ts +44 -0
  141. package/src/plugins/filesystem/plugin.ts +8 -6
  142. package/src/plugins/mailbox/mailbox.integration.test.ts +12 -18
  143. package/src/plugins/resources/plugin.ts +4 -1
  144. package/src/plugins/uploads/plugin.ts +212 -47
  145. package/src/plugins/uploads/preprocessors/image-classifier.test.ts +142 -0
  146. package/src/plugins/uploads/preprocessors/image-classifier.ts +13 -1
  147. package/src/plugins/uploads/preprocessors/markitdown-preprocessor.ts +8 -8
  148. package/src/plugins/uploads/preprocessors/zip-preprocessor.ts +37 -17
  149. package/src/plugins/uploads/state.ts +1 -1
  150. package/src/plugins/uploads/uploads.integration.test.ts +123 -0
  151. package/src/plugins/user-chat/plugin.ts +60 -3
  152. package/src/plugins/user-chat/schema.ts +10 -1
  153. package/src/plugins/user-chat/user-chat.integration.test.ts +99 -0
  154. package/src/transport/http/routes/upload.ts +87 -0
@@ -613,6 +613,129 @@ describe('uploads plugin', () => {
613
613
  await harness.shutdown()
614
614
  })
615
615
 
616
+ // =========================================================================
617
+ // uploadAsync method
618
+ // =========================================================================
619
+
620
+ it('uploadAsync returns processing immediately, then statusChanged → ready', async () => {
621
+ const harness = new TestHarness({
622
+ presets: [createTestPreset()],
623
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
624
+ })
625
+
626
+ const session = await harness.createSession('test')
627
+ const fileContent = Buffer.from('Hello, async world!')
628
+
629
+ const result = await session.callPluginMethod('uploads.uploadAsync', {
630
+ sessionId: String(session.sessionId),
631
+ filename: 'async.txt',
632
+ mimeType: 'text/plain',
633
+ size: fileContent.length,
634
+ fileBuffer: fileContent,
635
+ })
636
+
637
+ const data = okValue(
638
+ result,
639
+ z.object({ uploadId: z.string(), status: z.enum(['processing']) }),
640
+ )
641
+ expect(data.status).toBe('processing')
642
+
643
+ // Wait for the terminal statusChanged notification (ready or failed).
644
+ const terminal = await harness.notifications.waitFor((n) => {
645
+ if (n.pluginName !== 'uploads' || n.type !== 'uploadStatusChanged') return false
646
+ const p = n.payload as { uploadId: string; status: string }
647
+ return p.uploadId === data.uploadId && (p.status === 'ready' || p.status === 'failed')
648
+ })
649
+ expect((terminal.payload as { status: string }).status).toBe('ready')
650
+
651
+ // Two attachment_uploaded events were emitted: processing → ready.
652
+ const events = await session.getEventsByType(uploadEvents, 'attachment_uploaded')
653
+ const own = events.filter((e) => String(e.uploadId) === data.uploadId)
654
+ expect(own).toHaveLength(2)
655
+ expect(own[0].status).toBe('processing')
656
+ expect(own[1].status).toBe('ready')
657
+
658
+ // State only carries ready uploads — processing event is filtered by reducer.
659
+ const uploads = selectPluginState<UploadsState>(session.state, 'uploads')
660
+ if (!uploads) throw new Error('Expected uploads state')
661
+ expect(uploads.pending).toHaveLength(1)
662
+ expect(uploads.pending[0]?.uploadId).toBe(data.uploadId)
663
+ expect(uploads.pending[0]?.status).toBe('ready')
664
+
665
+ // First notification was processing.
666
+ const all = harness.notifications.getByType('uploads', 'uploadStatusChanged')
667
+ expect(all.length).toBeGreaterThanOrEqual(2)
668
+ expect((all[0]?.payload as { status: string }).status).toBe('processing')
669
+
670
+ await harness.shutdown()
671
+ })
672
+
673
+ it('uploadAsync rejects oversize file synchronously (no event)', async () => {
674
+ const harness = new TestHarness({
675
+ presets: [createTestPreset()],
676
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
677
+ })
678
+
679
+ const session = await harness.createSession('test')
680
+
681
+ const result = await session.callPluginMethod('uploads.uploadAsync', {
682
+ sessionId: String(session.sessionId),
683
+ filename: 'huge.txt',
684
+ mimeType: 'text/plain',
685
+ size: 11 * 1024 * 1024,
686
+ fileBuffer: Buffer.from('tiny'),
687
+ })
688
+
689
+ expect(result.ok).toBe(false)
690
+ if (!result.ok) {
691
+ expect(result.error.message).toContain('File too large')
692
+ }
693
+
694
+ const events = await session.getEventsByType(uploadEvents, 'attachment_uploaded')
695
+ expect(events).toHaveLength(0)
696
+
697
+ await harness.shutdown()
698
+ })
699
+
700
+ it('uploadAsync — listPending exposes processing then ready', async () => {
701
+ const harness = new TestHarness({
702
+ presets: [createTestPreset()],
703
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
704
+ })
705
+
706
+ const session = await harness.createSession('test')
707
+
708
+ const result = await session.callPluginMethod('uploads.uploadAsync', {
709
+ sessionId: String(session.sessionId),
710
+ filename: 'list-async.txt',
711
+ mimeType: 'text/plain',
712
+ size: 4,
713
+ fileBuffer: Buffer.from('data'),
714
+ })
715
+
716
+ const data = okValue(
717
+ result,
718
+ z.object({ uploadId: z.string(), status: z.enum(['processing']) }),
719
+ )
720
+
721
+ // Wait for terminal notification so the second meta.json write has landed.
722
+ await harness.notifications.waitFor((n) => {
723
+ if (n.pluginName !== 'uploads' || n.type !== 'uploadStatusChanged') return false
724
+ const p = n.payload as { uploadId: string; status: string }
725
+ return p.uploadId === data.uploadId && p.status === 'ready'
726
+ })
727
+
728
+ const listResult = await session.callPluginMethod('uploads.listPending', {
729
+ sessionId: String(session.sessionId),
730
+ })
731
+ const list = okValue(listResult, listPendingSchema)
732
+ const ours = list.uploads.find((u) => u.uploadId === data.uploadId)
733
+ expect(ours).toBeDefined()
734
+ expect(ours?.status).toBe('ready')
735
+
736
+ await harness.shutdown()
737
+ })
738
+
616
739
  it('load deleted upload → error (not ready)', async () => {
617
740
  const harness = new TestHarness({
618
741
  presets: [createTestPreset()],
@@ -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
  // =========================================================================
@@ -113,6 +113,93 @@ export function createUploadRoutes(): Hono<AppEnv> {
113
113
  )
114
114
  })
115
115
 
116
+ /**
117
+ * POST /sessions/:sessionId/upload-async
118
+ *
119
+ * Async variant of /upload — returns immediately with status: 'processing'
120
+ * and continues preprocessing in the background. Clients should listen for
121
+ * the `uploads.uploadStatusChanged` notification to learn when the upload
122
+ * becomes `ready` or `failed`, or fall back to polling `uploads.listPending`.
123
+ *
124
+ * Form fields: same as /upload.
125
+ *
126
+ * Response:
127
+ * - 202: { uploadId, status: 'processing' }
128
+ * - 400: Validation error
129
+ * - 404: Session not found
130
+ */
131
+ app.post('/:sessionId/upload-async', async (c: AppContext) => {
132
+ const { sessionRuntime, logger } = getServices(c)
133
+ const sessionId = SessionId(c.req.param('sessionId')!)
134
+
135
+ const sessionResult = await sessionRuntime.getSession(sessionId)
136
+ if (!sessionResult.ok) {
137
+ return c.json(
138
+ { error: { type: 'session_not_found', message: `Session not found: ${sessionId}` } },
139
+ 404,
140
+ )
141
+ }
142
+
143
+ let body: Record<string, string | File>
144
+ try {
145
+ body = await c.req.parseBody()
146
+ } catch {
147
+ return c.json(
148
+ { error: { type: 'parse_error', message: 'Failed to parse multipart form data' } },
149
+ 400,
150
+ )
151
+ }
152
+
153
+ const file = body.file
154
+ if (!file || !(file instanceof File)) {
155
+ return c.json(
156
+ { error: { type: 'validation_error', message: 'No file provided' } },
157
+ 400,
158
+ )
159
+ }
160
+
161
+ const fileBuffer = Buffer.from(await file.arrayBuffer())
162
+
163
+ const result = await sessionRuntime.callPluginMethod(sessionId, 'uploads.uploadAsync', {
164
+ sessionId: String(sessionId),
165
+ filename: file.name,
166
+ mimeType: file.type,
167
+ size: file.size,
168
+ fileBuffer,
169
+ })
170
+
171
+ if (!result.ok) {
172
+ return c.json(
173
+ { error: { type: result.error.type, message: result.error.type === 'validation_error' ? result.error.message : 'Upload failed' } },
174
+ 400,
175
+ )
176
+ }
177
+
178
+ const uploadResult = result.value
179
+ if (typeof uploadResult !== 'object' || uploadResult === null || !('uploadId' in uploadResult)) {
180
+ return c.json(
181
+ { error: { type: 'internal_error', message: 'Plugin did not return expected result' } },
182
+ 500,
183
+ )
184
+ }
185
+
186
+ logger.info('File upload accepted (async)', {
187
+ sessionId,
188
+ uploadId: uploadResult.uploadId,
189
+ filename: file.name,
190
+ mimeType: file.type,
191
+ size: file.size,
192
+ })
193
+
194
+ return c.json(
195
+ {
196
+ uploadId: uploadResult.uploadId,
197
+ status: 'status' in uploadResult ? uploadResult.status : 'processing',
198
+ },
199
+ 202,
200
+ )
201
+ })
202
+
116
203
  /**
117
204
  * POST /sessions/:sessionId/upload-from-url
118
205
  *