@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,249 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { AgentId } from '~/core/agents/schema.js'
3
+ import { MockLLMProvider } from '~/core/llm/mock.js'
4
+ import { ToolCallId } from '~/core/tools/schema.js'
5
+ import { agentsPlugin } from '~/plugins/agents/plugin.js'
6
+ import { mailboxEvents } from '~/plugins/mailbox/index.js'
7
+ import { createMultiAgentPreset, TestHarness, type TestSession } from '~/testing/index.js'
8
+
9
+ /**
10
+ * Helper — wait until at least one supervision message has landed in the parent's
11
+ * mailbox (or timeout). We poll because supervision ticks fire on real timers.
12
+ */
13
+ async function waitForSupervisorMessage(
14
+ session: TestSession,
15
+ toAgentId: AgentId,
16
+ timeoutMs = 2000,
17
+ ): Promise<{ message: { from: unknown; content: string } } | undefined> {
18
+ const deadline = Date.now() + timeoutMs
19
+ while (Date.now() < deadline) {
20
+ const events = await session.getEventsByType(mailboxEvents, 'mailbox_message')
21
+ const found = events.find((e) =>
22
+ e.message.from === 'supervisor'
23
+ && e.toAgentId === toAgentId
24
+ && typeof e.message.content === 'string',
25
+ )
26
+ if (found) return found
27
+ await new Promise((r) => setTimeout(r, 25))
28
+ }
29
+ return undefined
30
+ }
31
+
32
+ describe('agents plugin supervision', () => {
33
+ it('parent with active children receives a periodic <children-status> snapshot', async () => {
34
+ let orchestratorCalls = 0
35
+ let workerCalls = 0
36
+
37
+ const harness = new TestHarness({
38
+ presets: [{
39
+ ...createMultiAgentPreset([
40
+ { name: 'worker', system: 'Worker agent.', tools: [], agents: [] },
41
+ ], { orchestratorSystem: 'Orchestrator agent.' }),
42
+ plugins: [{ pluginName: 'agents', definition: agentsPlugin, config: { superviseChildrenIntervalMs: 100 } }],
43
+ }],
44
+ mockHandler: (request) => {
45
+ if (request.systemPrompt.includes('Orchestrator')) {
46
+ orchestratorCalls++
47
+ if (orchestratorCalls === 1) {
48
+ return {
49
+ content: null,
50
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker', input: { message: 'Long-running task' } }],
51
+ finishReason: 'stop',
52
+ metrics: MockLLMProvider.defaultMetrics(),
53
+ }
54
+ }
55
+ // Subsequent calls: orchestrator does nothing more, just acknowledges.
56
+ return { content: 'noted', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
57
+ }
58
+ // Worker: takes a long time — say something but never reports back.
59
+ workerCalls++
60
+ return { content: `Working on step ${workerCalls}`, toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
61
+ },
62
+ })
63
+
64
+ const session = await harness.createSession('test')
65
+ await session.sendMessage('Start')
66
+
67
+ // Orchestrator is the entry agent in this preset. Wait for a tick.
68
+ const orchestratorId = session.getEntryAgentId()!
69
+ const supervisorMsg = await waitForSupervisorMessage(session as never, orchestratorId)
70
+
71
+ expect(supervisorMsg).toBeDefined()
72
+ expect(supervisorMsg!.message.content).toContain('<children-status>')
73
+ expect(supervisorMsg!.message.content).toContain('worker_1')
74
+ // Cumulative LLM call count should be present
75
+ expect(supervisorMsg!.message.content).toMatch(/worker_1[^,\n]*,[^,\n]*,\s*\d+ tools,\s*\d+ llm/)
76
+
77
+ await harness.shutdown()
78
+ })
79
+
80
+ it('default (no config) → supervision disabled, no tick fires', async () => {
81
+ const harness = new TestHarness({
82
+ presets: [createMultiAgentPreset([
83
+ { name: 'worker', system: 'Worker agent.', tools: [], agents: [] },
84
+ ], { orchestratorSystem: 'Orchestrator agent.' })],
85
+ mockHandler: (request) => {
86
+ if (request.systemPrompt.includes('Orchestrator')) {
87
+ return {
88
+ content: null,
89
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker', input: { message: 'Do work' } }],
90
+ finishReason: 'stop',
91
+ metrics: MockLLMProvider.defaultMetrics(),
92
+ }
93
+ }
94
+ return { content: 'Working', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
95
+ },
96
+ })
97
+
98
+ const session = await harness.createSession('test')
99
+ await session.sendMessage('Start')
100
+
101
+ // Wait long enough for ticks if they were enabled (they shouldn't).
102
+ await new Promise((r) => setTimeout(r, 300))
103
+
104
+ const events = await session.getEventsByType(mailboxEvents, 'mailbox_message')
105
+ const supervisorMessages = events.filter(e => e.message.from === 'supervisor')
106
+ expect(supervisorMessages).toHaveLength(0)
107
+
108
+ await harness.shutdown()
109
+ })
110
+
111
+ it('parent without children → no tick fires', async () => {
112
+ const harness = new TestHarness({
113
+ presets: [{
114
+ ...createMultiAgentPreset([
115
+ { name: 'worker', system: 'Worker agent.', tools: [], agents: [] },
116
+ ], { orchestratorSystem: 'Orchestrator agent.' }),
117
+ plugins: [{ pluginName: 'agents', definition: agentsPlugin, config: { superviseChildrenIntervalMs: 100 } }],
118
+ }],
119
+ mockHandler: (request) => {
120
+ // Orchestrator never spawns anyone.
121
+ if (request.systemPrompt.includes('Orchestrator')) {
122
+ return { content: 'Done without spawning', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
123
+ }
124
+ return { content: 'unused', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
125
+ },
126
+ })
127
+
128
+ const session = await harness.createSession('test')
129
+ await session.sendAndWaitForIdle('Start')
130
+
131
+ // Give supervision plenty of room to fire (it shouldn't).
132
+ await new Promise((r) => setTimeout(r, 300))
133
+
134
+ const events = await session.getEventsByType(mailboxEvents, 'mailbox_message')
135
+ const supervisorMessages = events.filter(e => e.message.from === 'supervisor')
136
+ expect(supervisorMessages).toHaveLength(0)
137
+
138
+ await harness.shutdown()
139
+ })
140
+
141
+ it('snapshot includes "first words..last words" preview of last assistant turn', async () => {
142
+ let orchestratorCalls = 0
143
+
144
+ const harness = new TestHarness({
145
+ presets: [{
146
+ ...createMultiAgentPreset([
147
+ { name: 'worker', system: 'Worker agent.', tools: [], agents: [] },
148
+ ], { orchestratorSystem: 'Orchestrator agent.' }),
149
+ plugins: [{ pluginName: 'agents', definition: agentsPlugin, config: { superviseChildrenIntervalMs: 100 } }],
150
+ }],
151
+ mockHandler: (request) => {
152
+ if (request.systemPrompt.includes('Orchestrator')) {
153
+ orchestratorCalls++
154
+ if (orchestratorCalls === 1) {
155
+ return {
156
+ content: null,
157
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker', input: { message: 'Long task' } }],
158
+ finishReason: 'stop',
159
+ metrics: MockLLMProvider.defaultMetrics(),
160
+ }
161
+ }
162
+ return { content: 'ack', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
163
+ }
164
+ // Worker says a long sentence that should be truncated to first..last words
165
+ return {
166
+ content: 'Started fetching data and now I am running through the pipeline analyzing the response carefully',
167
+ toolCalls: [],
168
+ finishReason: 'stop',
169
+ metrics: MockLLMProvider.defaultMetrics(),
170
+ }
171
+ },
172
+ })
173
+
174
+ const session = await harness.createSession('test')
175
+ await session.sendMessage('Start')
176
+
177
+ const orchestratorId = session.getEntryAgentId()!
178
+ const msg = await waitForSupervisorMessage(session as never, orchestratorId)
179
+
180
+ expect(msg).toBeDefined()
181
+ // Should contain both head (first 5 words) and tail (last 5 words), joined by ".."
182
+ expect(msg!.message.content).toContain('Started fetching data and now')
183
+ expect(msg!.message.content).toContain('pipeline analyzing the response carefully')
184
+ expect(msg!.message.content).toMatch(/\.\.pipeline/)
185
+
186
+ await harness.shutdown()
187
+ })
188
+
189
+ it('server restart re-establishes timers via onSessionReady', async () => {
190
+ const sharedEventStore = new (await import('~/core/events/memory.js')).MemoryEventStore()
191
+
192
+ // Counter shared across phases — phase 1 spawns once, then orchestrator goes idle;
193
+ // phase 2 just acknowledges any wake-up triggered by the supervision tick.
194
+ let orchestratorCalls = 0
195
+
196
+ const buildHarness = (intervalMs: number | undefined) => new TestHarness({
197
+ eventStore: sharedEventStore,
198
+ presets: [{
199
+ ...createMultiAgentPreset([
200
+ { name: 'worker', system: 'Worker agent.', tools: [], agents: [] },
201
+ ], { orchestratorSystem: 'Orchestrator agent.' }),
202
+ ...(intervalMs !== undefined && {
203
+ plugins: [{ pluginName: 'agents', definition: agentsPlugin, config: { superviseChildrenIntervalMs: intervalMs } }],
204
+ }),
205
+ }],
206
+ mockHandler: (request) => {
207
+ if (request.systemPrompt.includes('Orchestrator')) {
208
+ orchestratorCalls++
209
+ if (orchestratorCalls === 1) {
210
+ return {
211
+ content: null,
212
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker', input: { message: 'Long task' } }],
213
+ finishReason: 'stop',
214
+ metrics: MockLLMProvider.defaultMetrics(),
215
+ }
216
+ }
217
+ return { content: 'noted', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
218
+ }
219
+ return { content: 'still working', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
220
+ },
221
+ })
222
+
223
+ // Phase 1: create session with supervision DISABLED (default) so no ticks pre-restart.
224
+ const harness1 = buildHarness(undefined)
225
+ const session1 = await harness1.createSession('test')
226
+ await session1.sendAndWaitForIdle('Start')
227
+ const hasWorker = () => {
228
+ for (const agent of session1.state.agents.values()) {
229
+ if (agent.definitionName === 'worker') return true
230
+ }
231
+ return false
232
+ }
233
+ expect(hasWorker()).toBe(true)
234
+ const sessionId = session1.sessionId
235
+ await harness1.shutdown()
236
+
237
+ // Phase 2: restart with supervision enabled. onSessionReady should
238
+ // re-arm the orchestrator's tick because it has a child.
239
+ const harness2 = buildHarness(100)
240
+ const session2 = await harness2.openSession(sessionId)
241
+
242
+ const orchestratorId = session2.getEntryAgentId()!
243
+ const msg = await waitForSupervisorMessage(session2, orchestratorId, 1500)
244
+ expect(msg).toBeDefined()
245
+ expect(msg!.message.content).toContain('worker_1')
246
+
247
+ await harness2.shutdown()
248
+ })
249
+ })
@@ -90,11 +90,31 @@ export const mailboxPlugin = definePlugin("mailbox")
90
90
  toAgentId: agentIdSchema,
91
91
  content: z.string(),
92
92
  debug: z.boolean().optional(),
93
+ fromSupervisor: z.boolean().optional(),
93
94
  }),
94
95
  output: z.object({ messageId: z.string() }),
95
96
  handler: async (ctx, input) => {
96
97
  const { toAgentId, content } = input;
97
98
 
99
+ if (input.fromSupervisor) {
100
+ // System-emitted supervision status — bypasses communication validation.
101
+ const messageId = generateMessageId(getNextMessageSeq(ctx.pluginState));
102
+ await ctx.emitEvent(
103
+ mailboxEvents.create("mailbox_message", {
104
+ toAgentId,
105
+ message: {
106
+ id: messageId,
107
+ from: "supervisor",
108
+ content,
109
+ timestamp: Date.now(),
110
+ consumed: false,
111
+ },
112
+ }),
113
+ );
114
+ ctx.scheduleAgent(toAgentId);
115
+ return Ok({ messageId });
116
+ }
117
+
98
118
  if (input.debug) {
99
119
  // Debug messages bypass communication validation
100
120
  const messageId = generateMessageId(getNextMessageSeq(ctx.pluginState));
@@ -58,6 +58,7 @@ export type MailboxMessageSender =
58
58
  | WorkerId
59
59
  | 'user'
60
60
  | 'debug'
61
+ | 'supervisor'
61
62
  | typeof ORCHESTRATOR_ROLE
62
63
  | typeof COMMUNICATOR_ROLE
63
64
 
@@ -11,6 +11,7 @@ export type MailboxMessageSender =
11
11
  | WorkerId
12
12
  | 'user'
13
13
  | 'debug'
14
+ | 'supervisor'
14
15
  | typeof ORCHESTRATOR_ROLE
15
16
  | typeof COMMUNICATOR_ROLE
16
17
 
@@ -23,7 +24,7 @@ export const mailboxEvents = createEventsFactory({
23
24
  from: z4.union([
24
25
  agentIdSchema,
25
26
  workerIdSchema,
26
- z4.enum(['user', 'debug', COMMUNICATOR_ROLE, ORCHESTRATOR_ROLE]),
27
+ z4.enum(['user', 'debug', 'supervisor', COMMUNICATOR_ROLE, ORCHESTRATOR_ROLE]),
27
28
  ]),
28
29
  content: z4.string(),
29
30
  timestamp: z4.number(),
@@ -2,11 +2,24 @@ import z from 'zod/v4'
2
2
  import { ValidationErrors } from '~/core/errors.js'
3
3
  import type { FileStore } from '~/core/file-store/types.js'
4
4
  import { definePlugin } from '~/core/plugins/plugin-builder.js'
5
+ import { SessionId } from '~/core/sessions/schema.js'
5
6
  import { Err, Ok } from '~/lib/utils/result.js'
6
7
  import type { PreprocessorRegistry } from './preprocessor.js'
7
8
  import { generateUploadId, type MessageAttachment, UploadId, type UploadMetadata } from './schema.js'
8
9
  import { type PendingUpload, uploadEvents, type UploadsState } from './state.js'
9
10
 
11
+ // ============================================================================
12
+ // Notification schemas
13
+ // ============================================================================
14
+
15
+ const statusChangedSchema = z.object({
16
+ sessionId: z.string(),
17
+ uploadId: z.string(),
18
+ status: z.enum(['processing', 'ready', 'failed']),
19
+ extractedContent: z.string().optional(),
20
+ error: z.string().optional(),
21
+ })
22
+
10
23
  // ============================================================================
11
24
  // Constants
12
25
  // ============================================================================
@@ -66,6 +79,69 @@ function formatUploadsForLLM(uploads: PendingUpload[], sessionRoot: string): str
66
79
  return blocks.join('\n')
67
80
  }
68
81
 
82
+ /**
83
+ * Run preprocessor (with timeout) and persist final upload metadata to disk.
84
+ * Returns the resolved status + extracted/derived data for the caller to emit.
85
+ */
86
+ async function runPreprocessAndPersist(args: {
87
+ uploadId: string
88
+ sessionId: SessionId
89
+ uploadStore: FileStore
90
+ filePath: string
91
+ filename: string
92
+ mimeType: string
93
+ size: number
94
+ createdAt: number
95
+ preprocessorRegistry?: PreprocessorRegistry
96
+ }): Promise<{
97
+ status: 'ready' | 'failed'
98
+ extractedContent?: string
99
+ derivedPaths?: string[]
100
+ error?: string
101
+ }> {
102
+ const preprocessor = args.preprocessorRegistry?.getForMimeType(args.mimeType)
103
+
104
+ let status: 'ready' | 'failed' = 'ready'
105
+ let extractedContent: string | undefined
106
+ let derivedPaths: string[] | undefined
107
+ let errorMessage: string | undefined
108
+
109
+ if (preprocessor) {
110
+ const processPromise = preprocessor.process(args.filePath, args.mimeType, {
111
+ files: args.uploadStore,
112
+ })
113
+ const timeoutPromise = sleep(PROCESSING_TIMEOUT_MS).then(() => ({
114
+ ok: false as const,
115
+ error: new Error('Processing timeout'),
116
+ }))
117
+ const result = await Promise.race([processPromise, timeoutPromise])
118
+ if (result.ok) {
119
+ extractedContent = result.value.extractedContent
120
+ derivedPaths = result.value.derivedPaths
121
+ } else {
122
+ status = 'failed'
123
+ errorMessage = result.error.message
124
+ }
125
+ }
126
+
127
+ const metadata: UploadMetadata = {
128
+ uploadId: UploadId(args.uploadId),
129
+ sessionId: args.sessionId,
130
+ filename: args.filename,
131
+ mimeType: args.mimeType,
132
+ size: args.size,
133
+ path: args.filePath,
134
+ status,
135
+ extractedContent,
136
+ derivedPaths,
137
+ createdAt: args.createdAt,
138
+ completedAt: Date.now(),
139
+ }
140
+ await args.uploadStore.write('meta.json', JSON.stringify(metadata, null, 2))
141
+
142
+ return { status, extractedContent, derivedPaths, error: errorMessage }
143
+ }
144
+
69
145
  // ============================================================================
70
146
  // Plugin
71
147
  // ============================================================================
@@ -73,6 +149,7 @@ function formatUploadsForLLM(uploads: PendingUpload[], sessionRoot: string): str
73
149
  export const uploadsPlugin = definePlugin('uploads')
74
150
  .pluginConfig<UploadsPluginConfig>()
75
151
  .events([uploadEvents])
152
+ .notification('uploadStatusChanged', { schema: statusChangedSchema })
76
153
  .state<UploadsState>({
77
154
  key: 'uploads',
78
155
  initial: (): UploadsState => ({ pending: [] }),
@@ -315,91 +392,179 @@ export const uploadsPlugin = definePlugin('uploads')
315
392
  handler: async (ctx, input) => {
316
393
  const { dataFileStore, preprocessorRegistry } = ctx.pluginConfig
317
394
 
318
- // Validate
319
395
  if (input.size > MAX_FILE_SIZE) {
320
396
  return Err(ValidationErrors.invalid(`File too large: max ${MAX_FILE_SIZE / (1024 * 1024)}MB`))
321
397
  }
322
-
323
398
  if (!isAllowedMimeType(input.mimeType)) {
324
399
  return Err(ValidationErrors.invalid(`Unsupported file type: ${input.mimeType}`))
325
400
  }
326
401
 
327
- // Generate upload ID and scoped store
328
402
  const uploadId = generateUploadId()
329
403
  const uploadStore = dataFileStore.scoped(`sessions/${input.sessionId}/uploads/${uploadId}`)
330
404
 
331
- // Write file to disk
332
405
  const writeResult = await uploadStore.write(input.filename, input.fileBuffer)
333
-
334
406
  if (!writeResult.ok) {
335
407
  return Err(ValidationErrors.invalid('Failed to write file'))
336
408
  }
337
409
 
338
- const filePath = writeResult.value.path
339
-
340
- // Run preprocessor (with timeout)
341
- let processingResult: 'success' | 'failed' | 'skipped' = 'skipped'
342
- let extractedContent: string | undefined
343
- let derivedPaths: string[] | undefined
410
+ const result = await runPreprocessAndPersist({
411
+ uploadId: String(uploadId),
412
+ sessionId: ctx.sessionId,
413
+ uploadStore,
414
+ filePath: writeResult.value.path,
415
+ filename: input.filename,
416
+ mimeType: input.mimeType,
417
+ size: input.size,
418
+ createdAt: Date.now(),
419
+ preprocessorRegistry,
420
+ })
344
421
 
345
- const preprocessor = preprocessorRegistry?.getForMimeType(input.mimeType)
422
+ await ctx.emitEvent(uploadEvents.create('attachment_uploaded', {
423
+ uploadId,
424
+ filename: input.filename,
425
+ mimeType: input.mimeType,
426
+ size: input.size,
427
+ status: result.status,
428
+ extractedContent: result.extractedContent,
429
+ derivedPaths: result.derivedPaths,
430
+ error: result.error,
431
+ }))
346
432
 
347
- if (preprocessor) {
348
- const processPromise = preprocessor.process(filePath, input.mimeType, {
349
- files: uploadStore,
350
- })
433
+ return Ok({
434
+ uploadId: String(uploadId),
435
+ status: result.status,
436
+ extractedContent: result.extractedContent,
437
+ })
438
+ },
439
+ })
440
+ .method('uploadAsync', {
441
+ input: z.object({
442
+ sessionId: z.string(),
443
+ filename: z.string(),
444
+ mimeType: z.string(),
445
+ size: z.number(),
446
+ fileBuffer: z.custom<Buffer>(),
447
+ }),
448
+ output: z.object({
449
+ uploadId: z.string(),
450
+ status: z.enum(['processing']),
451
+ }),
452
+ handler: async (ctx, input) => {
453
+ const { dataFileStore, preprocessorRegistry } = ctx.pluginConfig
351
454
 
352
- const timeoutPromise = sleep(PROCESSING_TIMEOUT_MS).then(() => ({
353
- ok: false as const,
354
- error: new Error('Processing timeout'),
355
- }))
455
+ if (input.size > MAX_FILE_SIZE) {
456
+ return Err(ValidationErrors.invalid(`File too large: max ${MAX_FILE_SIZE / (1024 * 1024)}MB`))
457
+ }
458
+ if (!isAllowedMimeType(input.mimeType)) {
459
+ return Err(ValidationErrors.invalid(`Unsupported file type: ${input.mimeType}`))
460
+ }
356
461
 
357
- const result = await Promise.race([processPromise, timeoutPromise])
462
+ const uploadId = generateUploadId()
463
+ const uploadIdStr = String(uploadId)
464
+ const uploadStore = dataFileStore.scoped(`sessions/${input.sessionId}/uploads/${uploadId}`)
358
465
 
359
- if (result.ok) {
360
- processingResult = 'success'
361
- extractedContent = result.value.extractedContent
362
- derivedPaths = result.value.derivedPaths
363
- } else {
364
- processingResult = 'failed'
365
- }
466
+ const writeResult = await uploadStore.write(input.filename, input.fileBuffer)
467
+ if (!writeResult.ok) {
468
+ return Err(ValidationErrors.invalid('Failed to write file'))
366
469
  }
367
470
 
368
- // Create upload metadata
369
- const now = Date.now()
370
- const uploadStatus = processingResult === 'failed' ? 'failed' as const : 'ready' as const
371
- const metadata: UploadMetadata = {
471
+ const filePath = writeResult.value.path
472
+ const createdAt = Date.now()
473
+
474
+ // Persist initial 'processing' metadata so listPending sees it before preprocessor finishes.
475
+ const processingMeta: UploadMetadata = {
372
476
  uploadId,
373
477
  sessionId: ctx.sessionId,
374
478
  filename: input.filename,
375
479
  mimeType: input.mimeType,
376
480
  size: input.size,
377
481
  path: filePath,
378
- status: uploadStatus,
379
- extractedContent,
380
- derivedPaths,
381
- createdAt: now,
382
- completedAt: now,
482
+ status: 'processing',
483
+ createdAt,
484
+ completedAt: createdAt,
383
485
  }
486
+ await uploadStore.write('meta.json', JSON.stringify(processingMeta, null, 2))
384
487
 
385
- // Save metadata
386
- await uploadStore.write('meta.json', JSON.stringify(metadata, null, 2))
387
-
388
- // Emit event
389
488
  await ctx.emitEvent(uploadEvents.create('attachment_uploaded', {
390
489
  uploadId,
391
490
  filename: input.filename,
392
491
  mimeType: input.mimeType,
393
492
  size: input.size,
394
- status: uploadStatus,
395
- extractedContent,
396
- derivedPaths,
493
+ status: 'processing',
397
494
  }))
495
+ ctx.notify('uploadStatusChanged', {
496
+ sessionId: input.sessionId,
497
+ uploadId: uploadIdStr,
498
+ status: 'processing',
499
+ })
500
+
501
+ // Capture refs from ctx before the handler returns — `notify`/`emitEvent`
502
+ // closures stay valid for the lifetime of the session, which in roj
503
+ // outlives any single handler call.
504
+ const { emitEvent, notify, logger } = ctx
505
+ const sessionId = ctx.sessionId
506
+
507
+ void (async () => {
508
+ try {
509
+ const result = await runPreprocessAndPersist({
510
+ uploadId: uploadIdStr,
511
+ sessionId,
512
+ uploadStore,
513
+ filePath,
514
+ filename: input.filename,
515
+ mimeType: input.mimeType,
516
+ size: input.size,
517
+ createdAt,
518
+ preprocessorRegistry,
519
+ })
520
+
521
+ await emitEvent(uploadEvents.create('attachment_uploaded', {
522
+ uploadId,
523
+ filename: input.filename,
524
+ mimeType: input.mimeType,
525
+ size: input.size,
526
+ status: result.status,
527
+ extractedContent: result.extractedContent,
528
+ derivedPaths: result.derivedPaths,
529
+ error: result.error,
530
+ }))
531
+ notify('uploadStatusChanged', {
532
+ sessionId: input.sessionId,
533
+ uploadId: uploadIdStr,
534
+ status: result.status,
535
+ extractedContent: result.extractedContent,
536
+ error: result.error,
537
+ })
538
+ } catch (err) {
539
+ const message = err instanceof Error ? err.message : String(err)
540
+ logger.error('Async upload processing crashed', err instanceof Error ? err : undefined, {
541
+ uploadId: uploadIdStr,
542
+ filename: input.filename,
543
+ })
544
+ try {
545
+ await emitEvent(uploadEvents.create('attachment_uploaded', {
546
+ uploadId,
547
+ filename: input.filename,
548
+ mimeType: input.mimeType,
549
+ size: input.size,
550
+ status: 'failed',
551
+ error: message,
552
+ }))
553
+ } catch {
554
+ // Even event emission failed — best-effort; nothing useful left to do.
555
+ }
556
+ notify('uploadStatusChanged', {
557
+ sessionId: input.sessionId,
558
+ uploadId: uploadIdStr,
559
+ status: 'failed',
560
+ error: message,
561
+ })
562
+ }
563
+ })()
398
564
 
399
565
  return Ok({
400
- uploadId: String(uploadId),
401
- status: uploadStatus,
402
- extractedContent,
566
+ uploadId: uploadIdStr,
567
+ status: 'processing' as const,
403
568
  })
404
569
  },
405
570
  })