@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.
- package/dist/bootstrap.d.ts +13 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +3 -1
- package/dist/bootstrap.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agents/agent.d.ts +25 -1
- package/dist/core/agents/agent.d.ts.map +1 -1
- package/dist/core/agents/agent.js +117 -21
- package/dist/core/agents/agent.js.map +1 -1
- package/dist/core/agents/config.d.ts +7 -0
- package/dist/core/agents/config.d.ts.map +1 -1
- package/dist/core/agents/context.d.ts +10 -0
- package/dist/core/agents/context.d.ts.map +1 -1
- package/dist/core/agents/state.d.ts +11 -3
- package/dist/core/agents/state.d.ts.map +1 -1
- package/dist/core/agents/state.js.map +1 -1
- package/dist/core/file-store/file-store.d.ts +5 -1
- package/dist/core/file-store/file-store.d.ts.map +1 -1
- package/dist/core/file-store/file-store.js +31 -21
- package/dist/core/file-store/file-store.js.map +1 -1
- package/dist/core/image/vips-resizer.test.js +26 -14
- package/dist/core/image/vips-resizer.test.js.map +1 -1
- package/dist/core/llm/anthropic.d.ts.map +1 -1
- package/dist/core/llm/anthropic.js +11 -8
- package/dist/core/llm/anthropic.js.map +1 -1
- package/dist/core/llm/cache-breakpoints.d.ts +5 -1
- package/dist/core/llm/cache-breakpoints.d.ts.map +1 -1
- package/dist/core/llm/cache-breakpoints.js +10 -5
- package/dist/core/llm/cache-breakpoints.js.map +1 -1
- package/dist/core/sessions/session.d.ts.map +1 -1
- package/dist/core/sessions/session.js +10 -0
- package/dist/core/sessions/session.js.map +1 -1
- package/dist/core/sessions/session.test.js +5 -0
- package/dist/core/sessions/session.test.js.map +1 -1
- package/dist/core/sessions/state.d.ts.map +1 -1
- package/dist/core/sessions/state.js +5 -1
- package/dist/core/sessions/state.js.map +1 -1
- package/dist/core/tools/executor.test.js +1 -0
- package/dist/core/tools/executor.test.js.map +1 -1
- package/dist/lib/utils/concurrency.d.ts +25 -0
- package/dist/lib/utils/concurrency.d.ts.map +1 -0
- package/dist/lib/utils/concurrency.js +69 -0
- package/dist/lib/utils/concurrency.js.map +1 -0
- package/dist/lib/utils/concurrency.test.d.ts +2 -0
- package/dist/lib/utils/concurrency.test.d.ts.map +1 -0
- package/dist/lib/utils/concurrency.test.js +135 -0
- package/dist/lib/utils/concurrency.test.js.map +1 -0
- package/dist/plugins/agent-status/plugin.d.ts.map +1 -1
- package/dist/plugins/agent-status/plugin.js +18 -26
- package/dist/plugins/agent-status/plugin.js.map +1 -1
- package/dist/plugins/context-compact/compaction-live.test.d.ts +17 -0
- package/dist/plugins/context-compact/compaction-live.test.d.ts.map +1 -0
- package/dist/plugins/context-compact/compaction-live.test.js +177 -0
- package/dist/plugins/context-compact/compaction-live.test.js.map +1 -0
- package/dist/plugins/context-compact/context-compact.integration.test.js +123 -3
- package/dist/plugins/context-compact/context-compact.integration.test.js.map +1 -1
- package/dist/plugins/context-compact/context-compactor.d.ts +47 -17
- package/dist/plugins/context-compact/context-compactor.d.ts.map +1 -1
- package/dist/plugins/context-compact/context-compactor.js +60 -36
- package/dist/plugins/context-compact/context-compactor.js.map +1 -1
- package/dist/plugins/context-compact/context-compactor.test.js +69 -103
- package/dist/plugins/context-compact/context-compactor.test.js.map +1 -1
- package/dist/plugins/context-compact/plugin.d.ts +9 -2
- package/dist/plugins/context-compact/plugin.d.ts.map +1 -1
- package/dist/plugins/context-compact/plugin.js +8 -4
- package/dist/plugins/context-compact/plugin.js.map +1 -1
- package/dist/plugins/filesystem/filesystem.integration.test.js +36 -0
- package/dist/plugins/filesystem/filesystem.integration.test.js.map +1 -1
- package/dist/plugins/filesystem/plugin.d.ts.map +1 -1
- package/dist/plugins/filesystem/plugin.js +8 -6
- package/dist/plugins/filesystem/plugin.js.map +1 -1
- package/dist/plugins/mailbox/mailbox.integration.test.js +9 -16
- package/dist/plugins/mailbox/mailbox.integration.test.js.map +1 -1
- package/dist/plugins/resources/plugin.d.ts.map +1 -1
- package/dist/plugins/resources/plugin.js +4 -1
- package/dist/plugins/resources/plugin.js.map +1 -1
- package/dist/plugins/uploads/plugin.d.ts +12 -0
- package/dist/plugins/uploads/plugin.d.ts.map +1 -1
- package/dist/plugins/uploads/plugin.js +188 -44
- package/dist/plugins/uploads/plugin.js.map +1 -1
- package/dist/plugins/uploads/preprocessors/image-classifier.d.ts +9 -0
- package/dist/plugins/uploads/preprocessors/image-classifier.d.ts.map +1 -1
- package/dist/plugins/uploads/preprocessors/image-classifier.js +4 -1
- package/dist/plugins/uploads/preprocessors/image-classifier.js.map +1 -1
- package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts +2 -0
- package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts.map +1 -0
- package/dist/plugins/uploads/preprocessors/image-classifier.test.js +113 -0
- package/dist/plugins/uploads/preprocessors/image-classifier.test.js.map +1 -0
- package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.d.ts.map +1 -1
- package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js +8 -7
- package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js.map +1 -1
- package/dist/plugins/uploads/preprocessors/zip-preprocessor.d.ts.map +1 -1
- package/dist/plugins/uploads/preprocessors/zip-preprocessor.js +35 -15
- package/dist/plugins/uploads/preprocessors/zip-preprocessor.js.map +1 -1
- package/dist/plugins/uploads/state.d.ts +1 -0
- package/dist/plugins/uploads/state.d.ts.map +1 -1
- package/dist/plugins/uploads/state.js +1 -1
- package/dist/plugins/uploads/state.js.map +1 -1
- package/dist/plugins/uploads/uploads.integration.test.js +97 -0
- package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
- package/dist/plugins/user-chat/plugin.d.ts +2 -0
- package/dist/plugins/user-chat/plugin.d.ts.map +1 -1
- package/dist/plugins/user-chat/plugin.js +47 -3
- package/dist/plugins/user-chat/plugin.js.map +1 -1
- package/dist/plugins/user-chat/schema.d.ts +10 -0
- package/dist/plugins/user-chat/schema.d.ts.map +1 -1
- package/dist/plugins/user-chat/schema.js +1 -0
- package/dist/plugins/user-chat/schema.js.map +1 -1
- package/dist/plugins/user-chat/user-chat.integration.test.js +86 -0
- package/dist/plugins/user-chat/user-chat.integration.test.js.map +1 -1
- package/dist/transport/http/routes/upload.d.ts.map +1 -1
- package/dist/transport/http/routes/upload.js +60 -0
- package/dist/transport/http/routes/upload.js.map +1 -1
- package/package.json +2 -2
- package/src/bootstrap.ts +3 -1
- package/src/config.ts +6 -0
- package/src/core/agents/agent.ts +134 -20
- package/src/core/agents/config.ts +7 -0
- package/src/core/agents/context.ts +11 -0
- package/src/core/agents/state.ts +11 -4
- package/src/core/file-store/file-store.ts +38 -18
- package/src/core/image/vips-resizer.test.ts +26 -15
- package/src/core/llm/anthropic.ts +19 -12
- package/src/core/llm/cache-breakpoints.ts +15 -6
- package/src/core/sessions/session.test.ts +6 -0
- package/src/core/sessions/session.ts +12 -0
- package/src/core/sessions/state.ts +5 -1
- package/src/core/tools/executor.test.ts +1 -0
- package/src/lib/utils/concurrency.test.ts +169 -0
- package/src/lib/utils/concurrency.ts +72 -0
- package/src/plugins/agent-status/plugin.ts +18 -25
- package/src/plugins/context-compact/compaction-live.test.ts +221 -0
- package/src/plugins/context-compact/context-compact.integration.test.ts +135 -3
- package/src/plugins/context-compact/context-compactor.test.ts +71 -110
- package/src/plugins/context-compact/context-compactor.ts +88 -43
- package/src/plugins/context-compact/plugin.ts +19 -10
- package/src/plugins/filesystem/filesystem.integration.test.ts +44 -0
- package/src/plugins/filesystem/plugin.ts +8 -6
- package/src/plugins/mailbox/mailbox.integration.test.ts +12 -18
- package/src/plugins/resources/plugin.ts +4 -1
- package/src/plugins/uploads/plugin.ts +212 -47
- package/src/plugins/uploads/preprocessors/image-classifier.test.ts +142 -0
- package/src/plugins/uploads/preprocessors/image-classifier.ts +13 -1
- package/src/plugins/uploads/preprocessors/markitdown-preprocessor.ts +8 -8
- package/src/plugins/uploads/preprocessors/zip-preprocessor.ts +37 -17
- package/src/plugins/uploads/state.ts +1 -1
- package/src/plugins/uploads/uploads.integration.test.ts +123 -0
- package/src/plugins/user-chat/plugin.ts +60 -3
- package/src/plugins/user-chat/schema.ts +10 -1
- package/src/plugins/user-chat/user-chat.integration.test.ts +99 -0
- 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
|
*
|