@@ -106,6 +106,102 @@ function buildRequest(body: unknown, secret = "test-webhook-secret") {
106106 } ) ;
107107}
108108
109+ async function seedOutstandingFollowUpBundle ( titles : string [ ] ) {
110+ const store = getDefaultInboxProcessingStore ( ) ;
111+
112+ seedInboxItemForProcessingTests ( {
113+ id : "inbox-seed" ,
114+ userId : "123" ,
115+ sourceEventId : "seed-event" ,
116+ rawText : "schedule it" ,
117+ normalizedText : "schedule it" ,
118+ processingStatus : "received" ,
119+ linkedTaskIds : [ ] ,
120+ createdAt : "2026-03-20T16:00:00.000Z"
121+ } ) ;
122+
123+ await store . saveTaskCaptureResult ( {
124+ inboxItemId : "inbox-seed" ,
125+ confidence : 1 ,
126+ plannerRun : {
127+ id : "ignored" ,
128+ userId : "123" ,
129+ inboxItemId : "inbox-seed" ,
130+ version : "test" ,
131+ modelInput : { } ,
132+ modelOutput : { } ,
133+ confidence : 1
134+ } as never ,
135+ tasks : titles . map ( ( title , index ) => ( {
136+ alias : `task_${ index + 1 } ` ,
137+ task : {
138+ userId : "123" ,
139+ sourceInboxItemId : "inbox-seed" ,
140+ lastInboxItemId : "inbox-seed" ,
141+ title,
142+ lifecycleState : "pending_schedule" ,
143+ externalCalendarEventId : null ,
144+ externalCalendarId : null ,
145+ scheduledStartAt : null ,
146+ scheduledEndAt : null ,
147+ calendarSyncStatus : "in_sync" ,
148+ calendarSyncUpdatedAt : null ,
149+ rescheduleCount : 0 ,
150+ lastFollowupAt : null ,
151+ followupReminderSentAt : null ,
152+ completedAt : null ,
153+ archivedAt : null ,
154+ priority : "medium" ,
155+ urgency : "medium"
156+ }
157+ } ) ) ,
158+ scheduleBlocks : titles . map ( ( _ , index ) => ( {
159+ id : `event-${ index + 1 } ` ,
160+ userId : "123" ,
161+ taskId : `task_${ index + 1 } ` ,
162+ startAt : "2026-03-20T16:00:00.000Z" ,
163+ endAt : "2026-03-20T17:00:00.000Z" ,
164+ confidence : 1 ,
165+ reason : "test" ,
166+ rescheduleCount : 0 ,
167+ externalCalendarId : "primary"
168+ } ) ) ,
169+ followUpMessage : "Scheduled it."
170+ } ) ;
171+
172+ const taskIds = listTasksForTests ( ) . map ( ( task ) => task . id ) ;
173+ await ( store as any ) . markFollowUpSent ( taskIds , "2026-03-20T17:00:00.000Z" ) ;
174+
175+ await recordOutgoingTelegramMessageIfNew ( {
176+ userId : "123" ,
177+ eventType : "telegram_followup_message" ,
178+ idempotencyKey : "telegram:followup:bundle-seed" ,
179+ payload : {
180+ kind : "initial" ,
181+ taskIds,
182+ items : taskIds . map ( ( taskId , index ) => ( { number : index + 1 , taskId, title : titles [ index ] ?? `Task ${ index + 1 } ` } ) ) ,
183+ text : titles . map ( ( title , index ) => `${ index + 1 } . ${ title } ` ) . join ( "\n" )
184+ } ,
185+ retryState : "sending"
186+ } ) ;
187+ await updateOutgoingTelegramMessage ( {
188+ idempotencyKey : "telegram:followup:bundle-seed" ,
189+ payload : {
190+ kind : "initial" ,
191+ taskIds,
192+ items : taskIds . map ( ( taskId , index ) => ( { number : index + 1 , taskId, title : titles [ index ] ?? `Task ${ index + 1 } ` } ) ) ,
193+ text : titles . map ( ( title , index ) => `${ index + 1 } . ${ title } ` ) . join ( "\n" ) ,
194+ attempts : 1
195+ } ,
196+ retryState : "sent"
197+ } ) ;
198+
199+ return {
200+ store,
201+ taskIds
202+ } ;
203+ }
204+
109205afterEach ( ( ) => {
110206 restoreEnv ( "DATABASE_URL" ) ;
111207 restoreEnv ( "OPENAI_API_KEY" ) ;
@@ -239,6 +335,152 @@ describe("telegram webhook route", () => {
239335 expect ( listScheduleBlocksForTests ( ) ) . toHaveLength ( 0 ) ;
240336 } ) ;
241337
338+ it ( "binds numbered followup replies and marks the selected task done" , async ( ) => {
339+ const { store } = await seedOutstandingFollowUpBundle ( [ "Review launch checklist" ] ) ;
340+
341+ const response = await handleTelegramWebhook (
342+ buildRequest ( {
343+ update_id : 41 ,
344+ message : {
345+ message_id : 6 ,
346+ date : 1_700_000_000 ,
347+ text : "1 done" ,
348+ chat : { id : 999 , type : "private" } ,
349+ from : { id : 123 , is_bot : false , first_name : "Max" }
350+ }
351+ } ) ,
352+ {
353+ store,
354+ followUpStore : store as any ,
355+ primeProcessingStore : seedInboxItemForProcessingTests
356+ }
357+ ) ;
358+
359+ expect ( response . status ) . toBe ( 200 ) ;
360+ expect ( ( response . body as { processing : { outcome : string } } ) . processing . outcome ) . toBe ( "completed_tasks" ) ;
361+ expect ( listTasksForTests ( ) [ 0 ] ) . toMatchObject ( {
362+ lifecycleState : "done"
363+ } ) ;
364+ } ) ;
365+
366+ it ( "accepts short natural-language followup replies that reference bundle numbers" , async ( ) => {
367+ const { store, taskIds } = await seedOutstandingFollowUpBundle ( [ "Task 1" , "Task 2" ] ) ;
368+
369+ const response = await handleTelegramWebhook (
370+ buildRequest ( {
371+ update_id : 142 ,
372+ message : {
373+ message_id : 107 ,
374+ date : 1_700_000_000 ,
375+ text : "finished 2" ,
376+ chat : { id : 999 , type : "private" } ,
377+ from : { id : 123 , is_bot : false , first_name : "Max" }
378+ }
379+ } ) ,
380+ {
381+ store,
382+ followUpStore : store as any ,
383+ primeProcessingStore : seedInboxItemForProcessingTests
384+ }
385+ ) ;
386+
387+ expect ( response . status ) . toBe ( 200 ) ;
388+ expect ( ( response . body as { processing : { outcome : string } } ) . processing . outcome ) . toBe ( "completed_tasks" ) ;
389+ expect ( listTasksForTests ( ) . find ( ( task ) => task . id === taskIds [ 0 ] ) ) . toMatchObject ( {
390+ lifecycleState : "awaiting_followup"
391+ } ) ;
392+ expect ( listTasksForTests ( ) . find ( ( task ) => task . id === taskIds [ 1 ] ) ) . toMatchObject ( {
393+ lifecycleState : "done"
394+ } ) ;
395+ } ) ;
396+
397+ it ( "accepts ordinal followup archive replies" , async ( ) => {
398+ const { store, taskIds } = await seedOutstandingFollowUpBundle ( [ "Task 1" , "Task 2" ] ) ;
399+
400+ const response = await handleTelegramWebhook (
401+ buildRequest ( {
402+ update_id : 143 ,
403+ message : {
404+ message_id : 108 ,
405+ date : 1_700_000_000 ,
406+ text : "archive the second one" ,
407+ chat : { id : 999 , type : "private" } ,
408+ from : { id : 123 , is_bot : false , first_name : "Max" }
409+ }
410+ } ) ,
411+ {
412+ store,
413+ followUpStore : store as any ,
414+ primeProcessingStore : seedInboxItemForProcessingTests
415+ }
416+ ) ;
417+
418+ expect ( response . status ) . toBe ( 200 ) ;
419+ expect ( ( response . body as { processing : { outcome : string } } ) . processing . outcome ) . toBe ( "archived_tasks" ) ;
420+ expect ( listTasksForTests ( ) . find ( ( task ) => task . id === taskIds [ 0 ] ) ) . toMatchObject ( {
421+ lifecycleState : "awaiting_followup"
422+ } ) ;
423+ expect ( listTasksForTests ( ) . find ( ( task ) => task . id === taskIds [ 1 ] ) ) . toMatchObject ( {
424+ lifecycleState : "archived"
425+ } ) ;
426+ } ) ;
427+
428+ it ( "asks for clarification on ambiguous followup replies with multiple unresolved items" , async ( ) => {
429+ const { store } = await seedOutstandingFollowUpBundle ( [ "Task 1" , "Task 2" ] ) ;
430+
431+ const response = await handleTelegramWebhook (
432+ buildRequest ( {
433+ update_id : 42 ,
434+ message : {
435+ message_id : 7 ,
436+ date : 1_700_000_000 ,
437+ text : "done" ,
438+ chat : { id : 999 , type : "private" } ,
439+ from : { id : 123 , is_bot : false , first_name : "Max" }
440+ }
441+ } ) ,
442+ {
443+ store,
444+ followUpStore : store as any ,
445+ primeProcessingStore : seedInboxItemForProcessingTests
446+ }
447+ ) ;
448+
449+ expect ( response . status ) . toBe ( 200 ) ;
450+ expect ( sendTelegramMessageMock ) . toHaveBeenCalledWith ( {
451+ chatId : "999" ,
452+ text : "Which one do you mean? Reply with the number or numbers."
453+ } ) ;
454+ } ) ;
455+
456+ it ( "lets mixed-intent messages fall through to the normal router even with outstanding followups" , async ( ) => {
457+ const { store } = await seedOutstandingFollowUpBundle ( [ "Review launch checklist" , "Send investor update" ] ) ;
458+
459+ const response = await handleTelegramWebhook (
460+ buildRequest ( {
461+ update_id : 144 ,
462+ message : {
463+ message_id : 109 ,
464+ date : 1_700_000_000 ,
465+ text : "1 done and schedule dentist tomorrow" ,
466+ chat : { id : 999 , type : "private" } ,
467+ from : { id : 123 , is_bot : false , first_name : "Max" }
468+ }
469+ } ) ,
470+ {
471+ store,
472+ followUpStore : store as any ,
473+ calendar : getDefaultCalendarAdapter ( ) ,
474+ primeProcessingStore : seedInboxItemForProcessingTests
475+ }
476+ ) ;
477+
478+ expect ( response . status ) . toBe ( 200 ) ;
479+ expect ( routeTurnWithResponsesMock ) . toHaveBeenCalledTimes ( 1 ) ;
480+ expect ( ( response . body as { processing : { outcome : string } } ) . processing . outcome ) . toBe ( "planned" ) ;
481+ expect ( listTasksForTests ( ) . filter ( ( task ) => task . lifecycleState === "done" ) ) . toHaveLength ( 0 ) ;
482+ } ) ;
483+
242484 it ( "also gates /start for unlinked users" , async ( ) => {
243485 resetGoogleCalendarConnectionStoreForTests ( ) ;
244486
0 commit comments