@roj-ai/sdk 0.1.12 → 0.1.14

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 (88) hide show
  1. package/dist/bootstrap.d.ts +18 -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/sessions/session-manager.d.ts.map +1 -1
  10. package/dist/core/sessions/session-manager.js +13 -5
  11. package/dist/core/sessions/session-manager.js.map +1 -1
  12. package/dist/lib/utils/concurrency.d.ts +25 -0
  13. package/dist/lib/utils/concurrency.d.ts.map +1 -0
  14. package/dist/lib/utils/concurrency.js +69 -0
  15. package/dist/lib/utils/concurrency.js.map +1 -0
  16. package/dist/lib/utils/concurrency.test.d.ts +2 -0
  17. package/dist/lib/utils/concurrency.test.d.ts.map +1 -0
  18. package/dist/lib/utils/concurrency.test.js +135 -0
  19. package/dist/lib/utils/concurrency.test.js.map +1 -0
  20. package/dist/plugins/agents/plugin.d.ts +20 -0
  21. package/dist/plugins/agents/plugin.d.ts.map +1 -1
  22. package/dist/plugins/agents/plugin.js +189 -2
  23. package/dist/plugins/agents/plugin.js.map +1 -1
  24. package/dist/plugins/agents/supervision.integration.test.d.ts +2 -0
  25. package/dist/plugins/agents/supervision.integration.test.d.ts.map +1 -0
  26. package/dist/plugins/agents/supervision.integration.test.js +215 -0
  27. package/dist/plugins/agents/supervision.integration.test.js.map +1 -0
  28. package/dist/plugins/mailbox/plugin.d.ts +1 -0
  29. package/dist/plugins/mailbox/plugin.d.ts.map +1 -1
  30. package/dist/plugins/mailbox/plugin.js +17 -0
  31. package/dist/plugins/mailbox/plugin.js.map +1 -1
  32. package/dist/plugins/mailbox/schema.d.ts +1 -1
  33. package/dist/plugins/mailbox/schema.d.ts.map +1 -1
  34. package/dist/plugins/mailbox/state.d.ts +2 -1
  35. package/dist/plugins/mailbox/state.d.ts.map +1 -1
  36. package/dist/plugins/mailbox/state.js +1 -1
  37. package/dist/plugins/mailbox/state.js.map +1 -1
  38. package/dist/plugins/uploads/plugin.d.ts +12 -0
  39. package/dist/plugins/uploads/plugin.d.ts.map +1 -1
  40. package/dist/plugins/uploads/plugin.js +188 -44
  41. package/dist/plugins/uploads/plugin.js.map +1 -1
  42. package/dist/plugins/uploads/preprocessors/image-classifier.d.ts +9 -0
  43. package/dist/plugins/uploads/preprocessors/image-classifier.d.ts.map +1 -1
  44. package/dist/plugins/uploads/preprocessors/image-classifier.js +4 -1
  45. package/dist/plugins/uploads/preprocessors/image-classifier.js.map +1 -1
  46. package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts +2 -0
  47. package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts.map +1 -0
  48. package/dist/plugins/uploads/preprocessors/image-classifier.test.js +113 -0
  49. package/dist/plugins/uploads/preprocessors/image-classifier.test.js.map +1 -0
  50. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.d.ts.map +1 -1
  51. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js +8 -7
  52. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js.map +1 -1
  53. package/dist/plugins/uploads/preprocessors/zip-preprocessor.d.ts.map +1 -1
  54. package/dist/plugins/uploads/preprocessors/zip-preprocessor.js +35 -15
  55. package/dist/plugins/uploads/preprocessors/zip-preprocessor.js.map +1 -1
  56. package/dist/plugins/uploads/state.d.ts +1 -0
  57. package/dist/plugins/uploads/state.d.ts.map +1 -1
  58. package/dist/plugins/uploads/state.js +1 -1
  59. package/dist/plugins/uploads/state.js.map +1 -1
  60. package/dist/plugins/uploads/uploads.integration.test.js +97 -0
  61. package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
  62. package/dist/transport/http/middleware/error-handler.d.ts +1 -1
  63. package/dist/transport/http/routes/upload.d.ts.map +1 -1
  64. package/dist/transport/http/routes/upload.js +60 -0
  65. package/dist/transport/http/routes/upload.js.map +1 -1
  66. package/dist/user-config.d.ts +14 -0
  67. package/dist/user-config.d.ts.map +1 -1
  68. package/dist/user-config.js.map +1 -1
  69. package/package.json +2 -2
  70. package/src/bootstrap.ts +3 -1
  71. package/src/config.ts +6 -0
  72. package/src/core/sessions/session-manager.ts +14 -5
  73. package/src/lib/utils/concurrency.test.ts +169 -0
  74. package/src/lib/utils/concurrency.ts +72 -0
  75. package/src/plugins/agents/plugin.ts +228 -3
  76. package/src/plugins/agents/supervision.integration.test.ts +249 -0
  77. package/src/plugins/mailbox/plugin.ts +20 -0
  78. package/src/plugins/mailbox/schema.ts +1 -0
  79. package/src/plugins/mailbox/state.ts +2 -1
  80. package/src/plugins/uploads/plugin.ts +212 -47
  81. package/src/plugins/uploads/preprocessors/image-classifier.test.ts +142 -0
  82. package/src/plugins/uploads/preprocessors/image-classifier.ts +13 -1
  83. package/src/plugins/uploads/preprocessors/markitdown-preprocessor.ts +8 -8
  84. package/src/plugins/uploads/preprocessors/zip-preprocessor.ts +37 -17
  85. package/src/plugins/uploads/state.ts +1 -1
  86. package/src/plugins/uploads/uploads.integration.test.ts +123 -0
  87. package/src/transport/http/routes/upload.ts +87 -0
  88. package/src/user-config.ts +15 -0
@@ -0,0 +1,142 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { SessionFileStore } from '~/core/file-store/file-store.js'
6
+ import { MockLLMProvider } from '~/core/llm/mock.js'
7
+ import { Semaphore } from '~/lib/utils/concurrency.js'
8
+ import { silentLogger } from '../../../lib/logger/logger.js'
9
+ import { createNodePlatform } from '../../../testing/node-platform.js'
10
+ import { ImageClassifierPreprocessor } from './image-classifier.js'
11
+
12
+ function defer<T = void>(): { promise: Promise<T>; resolve: (v: T) => void } {
13
+ let resolve!: (v: T) => void
14
+ const promise = new Promise<T>((res) => {
15
+ resolve = res
16
+ })
17
+ return { promise, resolve }
18
+ }
19
+
20
+ describe('ImageClassifierPreprocessor.gate', () => {
21
+ const platform = createNodePlatform()
22
+ let workDir: string
23
+
24
+ beforeAll(async () => {
25
+ workDir = await mkdtemp(join(tmpdir(), 'roj-classifier-gate-'))
26
+ })
27
+
28
+ afterAll(async () => {
29
+ await rm(workDir, { recursive: true, force: true }).catch(() => {})
30
+ })
31
+
32
+ it('caps concurrent vision LLM calls when a gate is provided', async () => {
33
+ const N = 8
34
+ const LIMIT = 3
35
+
36
+ // Track concurrency inside the mock LLM handler — each call increments
37
+ // `active` on entry, awaits a release barrier, then decrements on exit.
38
+ let active = 0
39
+ let peak = 0
40
+ const release = defer()
41
+
42
+ const llmProvider = new MockLLMProvider(async () => {
43
+ active++
44
+ peak = Math.max(peak, active)
45
+ await release.promise
46
+ active--
47
+ return {
48
+ content: 'desc',
49
+ toolCalls: [],
50
+ finishReason: 'stop',
51
+ metrics: MockLLMProvider.defaultMetrics(),
52
+ }
53
+ })
54
+
55
+ const gate = new Semaphore(LIMIT)
56
+ const classifier = new ImageClassifierPreprocessor({
57
+ llmProvider,
58
+ logger: silentLogger,
59
+ fs: platform.fs,
60
+ gate,
61
+ })
62
+
63
+ // Create N tiny dummy image files — content doesn't matter, classifier
64
+ // only stats them and hands the path to the (mocked) LLM.
65
+ const imagePaths = await Promise.all(
66
+ Array.from({ length: N }, async (_, i) => {
67
+ const p = join(workDir, `img-${i}.png`)
68
+ await writeFile(p, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
69
+ return p
70
+ }),
71
+ )
72
+
73
+ const fileStore = new SessionFileStore(workDir, undefined, false, platform.fs, 'session')
74
+
75
+ const tasks = imagePaths.map((p, i) =>
76
+ classifier.process(p, 'image/png', { files: fileStore.scoped(`img-${i}-meta`) }),
77
+ )
78
+
79
+ // Let the workers queue up; the first LIMIT should be in-flight.
80
+ await new Promise(r => setTimeout(r, 20))
81
+ expect(active).toBeLessThanOrEqual(LIMIT)
82
+ expect(active).toBe(LIMIT)
83
+
84
+ // Release everyone; wait for completion and check peak.
85
+ release.resolve()
86
+ const results = await Promise.all(tasks)
87
+
88
+ expect(peak).toBe(LIMIT)
89
+ expect(active).toBe(0)
90
+ expect(llmProvider.getCallCount()).toBe(N)
91
+ for (const r of results) {
92
+ expect(r.ok).toBe(true)
93
+ }
94
+ })
95
+
96
+ it('does not gate when no semaphore is provided (all run concurrently)', async () => {
97
+ const N = 6
98
+ let active = 0
99
+ let peak = 0
100
+ const release = defer()
101
+
102
+ const llmProvider = new MockLLMProvider(async () => {
103
+ active++
104
+ peak = Math.max(peak, active)
105
+ await release.promise
106
+ active--
107
+ return {
108
+ content: 'desc',
109
+ toolCalls: [],
110
+ finishReason: 'stop',
111
+ metrics: MockLLMProvider.defaultMetrics(),
112
+ }
113
+ })
114
+
115
+ const classifier = new ImageClassifierPreprocessor({
116
+ llmProvider,
117
+ logger: silentLogger,
118
+ fs: platform.fs,
119
+ })
120
+
121
+ const imagePaths = await Promise.all(
122
+ Array.from({ length: N }, async (_, i) => {
123
+ const p = join(workDir, `nogate-${i}.png`)
124
+ await writeFile(p, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
125
+ return p
126
+ }),
127
+ )
128
+
129
+ const fileStore = new SessionFileStore(workDir, undefined, false, platform.fs, 'session')
130
+
131
+ const tasks = imagePaths.map((p, i) =>
132
+ classifier.process(p, 'image/png', { files: fileStore.scoped(`nogate-${i}-meta`) }),
133
+ )
134
+
135
+ await new Promise(r => setTimeout(r, 20))
136
+ expect(active).toBe(N)
137
+
138
+ release.resolve()
139
+ await Promise.all(tasks)
140
+ expect(peak).toBe(N)
141
+ })
142
+ })
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { LLMProvider } from '~/core/llm/provider.js'
9
9
  import { ModelId } from '~/core/llm/schema.js'
10
+ import type { Semaphore } from '~/lib/utils/concurrency.js'
10
11
  import type { Result } from '~/lib/utils/result.js'
11
12
  import { Err, Ok } from '~/lib/utils/result.js'
12
13
  import type { FileSystem } from '~/platform/fs.js'
@@ -28,6 +29,13 @@ export interface ImageClassifierConfig {
28
29
  fs: FileSystem
29
30
  /** Whether to skip vision and just return metadata */
30
31
  skipVision?: boolean
32
+ /**
33
+ * Optional semaphore to bound concurrent vision LLM calls. All callers
34
+ * sharing one instance compete for the same set of permits — useful when
35
+ * recursive preprocessors (ZIP → docs → images) would otherwise fan out
36
+ * into many simultaneous inferences.
37
+ */
38
+ gate?: Semaphore
31
39
  }
32
40
 
33
41
  // ============================================================================
@@ -47,6 +55,7 @@ export class ImageClassifierPreprocessor implements Preprocessor {
47
55
  private readonly logger: Logger
48
56
  private readonly fs: FileSystem
49
57
  private readonly skipVision: boolean
58
+ private readonly gate: Semaphore | undefined
50
59
 
51
60
  constructor(config: ImageClassifierConfig) {
52
61
  this.llmProvider = config.llmProvider
@@ -54,6 +63,7 @@ export class ImageClassifierPreprocessor implements Preprocessor {
54
63
  this.logger = config.logger
55
64
  this.fs = config.fs
56
65
  this.skipVision = config.skipVision ?? false
66
+ this.gate = config.gate
57
67
  }
58
68
 
59
69
  async process(
@@ -124,7 +134,7 @@ export class ImageClassifierPreprocessor implements Preprocessor {
124
134
  ): Promise<string | null> {
125
135
  try {
126
136
  // Use file:// URL - resolved to base64 lazily in LLM provider
127
- const result = await this.llmProvider.inference({
137
+ const inferenceCall = () => this.llmProvider.inference({
128
138
  model: this.visionModel,
129
139
  systemPrompt: 'You are an image description assistant. Describe images concisely in 1-2 sentences.',
130
140
  messages: [
@@ -146,6 +156,8 @@ export class ImageClassifierPreprocessor implements Preprocessor {
146
156
  temperature: 0.3,
147
157
  })
148
158
 
159
+ const result = await (this.gate ? this.gate.run(inferenceCall) : inferenceCall())
160
+
149
161
  if (result.ok && result.value.content) {
150
162
  return result.value.content.trim()
151
163
  }
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { dirname } from 'node:path'
16
+ import { mapWithConcurrency } from '~/lib/utils/concurrency.js'
16
17
  import type { Result } from '~/lib/utils/result.js'
17
18
  import { Err, Ok } from '~/lib/utils/result.js'
18
19
  import type { FileSystem } from '~/platform/fs.js'
@@ -22,6 +23,7 @@ import type { Logger } from '../../../lib/logger/logger.js'
22
23
  import type { Preprocessor, PreprocessorContext, PreprocessorRegistry, PreprocessorResult } from '../preprocessor.js'
23
24
 
24
25
  const MAX_IMAGES = 50
26
+ const IMAGE_CLASSIFY_CONCURRENCY = 10
25
27
 
26
28
  function makeExec(processRunner: ProcessRunner) {
27
29
  return (cmd: string, args: string[]) => processRunner.execFile(cmd, args, { timeout: 60_000, maxBuffer: 50 * 1024 * 1024 })
@@ -228,19 +230,17 @@ export async function classifyExtractedImages(
228
230
  registry: PreprocessorRegistry,
229
231
  logger: Logger,
230
232
  ): Promise<Array<{ relativePath: string; description: string }>> {
231
- const results: Array<{ relativePath: string; description: string }> = []
232
-
233
233
  const listResult = await imageStore.list('', { maxDepth: 3 })
234
- if (!listResult.ok) return results
234
+ if (!listResult.ok) return []
235
235
 
236
236
  const imageFiles = listResult.value
237
237
  .filter(e => e.type === 'file' && IMAGE_EXT_RE.test(e.name))
238
238
  .sort((a, b) => a.name.localeCompare(b.name))
239
239
  .slice(0, MAX_IMAGES)
240
240
 
241
- for (const imgFile of imageFiles) {
241
+ const settled = await mapWithConcurrency(imageFiles, IMAGE_CLASSIFY_CONCURRENCY, async (imgFile) => {
242
242
  const imgPathResult = imageStore.realPath(imgFile.name)
243
- if (!imgPathResult.ok) continue
243
+ if (!imgPathResult.ok) return null
244
244
 
245
245
  const imgMime = guessImageMime(imgFile.name)
246
246
  let description = imgMime
@@ -255,8 +255,8 @@ export async function classifyExtractedImages(
255
255
  }
256
256
  }
257
257
 
258
- results.push({ relativePath: `${relativePrefix}/${imgFile.name}`, description })
259
- }
258
+ return { relativePath: `${relativePrefix}/${imgFile.name}`, description }
259
+ })
260
260
 
261
- return results
261
+ return settled.filter((r): r is { relativePath: string; description: string } => r !== null)
262
262
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { extname } from 'node:path'
11
+ import { mapWithConcurrency } from '~/lib/utils/concurrency.js'
11
12
  import type { Result } from '~/lib/utils/result.js'
12
13
  import { Err, Ok } from '~/lib/utils/result.js'
13
14
  import type { ProcessRunner } from '~/platform/process.js'
@@ -17,6 +18,7 @@ import type { Preprocessor, PreprocessorContext, PreprocessorRegistry, Preproces
17
18
  const MAX_DEPTH = 3
18
19
  const MAX_FILES = 500
19
20
  const MAX_TOTAL_SIZE = 100 * 1024 * 1024 // 100MB
21
+ const ZIP_FILE_CONCURRENCY = 10
20
22
 
21
23
  const MIME_MAP: Record<string, string> = {
22
24
  '.pdf': 'application/pdf',
@@ -116,38 +118,45 @@ export class ZipPreprocessor implements Preprocessor {
116
118
  return Err(new Error('Failed to list extracted files'))
117
119
  }
118
120
 
119
- const derivedPaths: string[] = []
120
- const manifest: string[] = []
121
- let fileCount = 0
122
- let totalSize = 0
123
-
124
121
  const files = listResult.value
125
122
  .filter(e => e.type === 'file')
126
123
  .sort((a, b) => a.name.localeCompare(b.name))
127
124
 
125
+ // Pick eligible files first (limits depend on cumulative iteration order, so this stays sequential)
126
+ const eligible: typeof files = []
127
+ let totalSize = 0
128
+ let truncationNotice: string | null = null
129
+
128
130
  for (const file of files) {
129
- if (fileCount >= MAX_FILES) {
130
- manifest.push(`... (truncated, ${files.length - fileCount} more files)`)
131
+ if (eligible.length >= MAX_FILES) {
132
+ truncationNotice = `... (truncated, ${files.length - eligible.length} more files)`
131
133
  break
132
134
  }
133
-
134
135
  const fileSize = file.size ?? 0
135
- totalSize += fileSize
136
- if (totalSize > MAX_TOTAL_SIZE) {
137
- manifest.push('... (total size limit reached)')
136
+ if (totalSize + fileSize > MAX_TOTAL_SIZE) {
137
+ truncationNotice = '... (total size limit reached)'
138
138
  break
139
139
  }
140
+ totalSize += fileSize
141
+ eligible.push(file)
142
+ }
143
+
144
+ const fileCount = eligible.length
140
145
 
141
- fileCount++
146
+ // Process eligible files in parallel with bounded concurrency
147
+ const processed = await mapWithConcurrency(eligible, ZIP_FILE_CONCURRENCY, async (file) => {
148
+ const collectedPaths: string[] = []
142
149
 
143
150
  const fileRealPath = extractStore.realPath(file.name)
144
151
  if (!fileRealPath.ok) {
145
- manifest.push(`- ${file.name} (path resolution failed)`)
146
- continue
152
+ return {
153
+ manifestEntry: `- ${file.name} (path resolution failed)`,
154
+ derivedPaths: collectedPaths,
155
+ }
147
156
  }
148
157
 
149
158
  const relativePath = `extracted/${file.name}`
150
- derivedPaths.push(relativePath)
159
+ collectedPaths.push(relativePath)
151
160
 
152
161
  const mime = getMimeType(file.name)
153
162
  let contentSummary = ''
@@ -171,7 +180,7 @@ export class ZipPreprocessor implements Preprocessor {
171
180
  if (subResult.ok) {
172
181
  if (subResult.value.derivedPaths) {
173
182
  for (const dp of subResult.value.derivedPaths) {
174
- derivedPaths.push(`extracted/${file.name}-content/${dp}`)
183
+ collectedPaths.push(`extracted/${file.name}-content/${dp}`)
175
184
  }
176
185
  }
177
186
  if (subResult.value.extractedContent) {
@@ -186,8 +195,19 @@ export class ZipPreprocessor implements Preprocessor {
186
195
  }
187
196
  }
188
197
 
189
- manifest.push(`- ${file.name} (${formatSize(fileSize)})${contentSummary}`)
198
+ return {
199
+ manifestEntry: `- ${file.name} (${formatSize(file.size ?? 0)})${contentSummary}`,
200
+ derivedPaths: collectedPaths,
201
+ }
202
+ })
203
+
204
+ const derivedPaths: string[] = []
205
+ const manifest: string[] = []
206
+ for (const item of processed) {
207
+ derivedPaths.push(...item.derivedPaths)
208
+ manifest.push(item.manifestEntry)
190
209
  }
210
+ if (truncationNotice) manifest.push(truncationNotice)
191
211
 
192
212
  const fullManifest = `## ZIP Contents (${fileCount} files)\n\n${manifest.join('\n')}`
193
213
 
@@ -9,7 +9,7 @@ export const uploadEvents = createEventsFactory({
9
9
  filename: z4.string(),
10
10
  mimeType: z4.string(),
11
11
  size: z4.number(),
12
- status: z4.enum(['ready', 'failed']),
12
+ status: z4.enum(['processing', 'ready', 'failed']),
13
13
  extractedContent: z4.string().optional(),
14
14
  derivedPaths: z4.array(z4.string()).optional(),
15
15
  error: z4.string().optional(),
@@ -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()],
@@ -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
  *
@@ -32,6 +32,21 @@ export interface RojConfig {
32
32
  * `path` is resolved relative to the config file directory.
33
33
  */
34
34
  localResources?: LocalResource[]
35
+ /**
36
+ * Bundle runtime delivery mode. When `external: true`, `roj build` emits a
37
+ * bundle that imports `@roj-ai/*` from the sandbox's installed SDK at run
38
+ * time instead of inlining it. The platform resolves the actual SDK version
39
+ * from `rojVersion` (build-time) and the lock policy below; only `lockMinor`
40
+ * is user-controllable — major is always locked, patch always floats.
41
+ */
42
+ runtime?: RuntimeConfig
43
+ }
44
+
45
+ export interface RuntimeConfig {
46
+ /** Opt into externalized SDK loading. Default: false (self-contained bundle). */
47
+ external?: boolean
48
+ /** When true, only patch versions float; minor is pinned to build-time. Default: true. Ignored when external !== true. */
49
+ lockMinor?: boolean
35
50
  }
36
51
 
37
52
  export interface LocalResource {