@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.
- package/dist/bootstrap.d.ts +1 -0
- package/dist/bootstrap.d.ts.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 +3 -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/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/preprocessors/image-classifier.d.ts.map +1 -1
- package/dist/plugins/uploads/preprocessors/image-classifier.js +15 -2
- package/dist/plugins/uploads/preprocessors/image-classifier.js.map +1 -1
- package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.d.ts.map +1 -1
- package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js +72 -19
- package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.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/package.json +2 -2
- 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 +4 -0
- package/src/core/sessions/state.ts +5 -1
- package/src/core/tools/executor.test.ts +1 -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/preprocessors/image-classifier.ts +15 -2
- package/src/plugins/uploads/preprocessors/markitdown-preprocessor.ts +89 -20
- 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
|
@@ -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[]
|
|
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.
|
|
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(
|
|
167
|
-
'
|
|
168
|
-
format,
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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(
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
// =========================================================================
|