@@ -193,6 +193,54 @@ describe("deriveConversationReplyState", () => {
193193 ] ) ;
194194 expect ( result . discourseState ?. mode ) . toBe ( "confirming" ) ;
195195 } ) ;
196+
197+ it ( "persists pending_write_contract when resolvedContract is provided" , ( ) => {
198+ const contract = { requiredSlots : [ "day" , "time" ] as ( "day" | "time" | "duration" | "target" ) [ ] , intentKind : "plan" as const } ;
199+
200+ const result = deriveConversationReplyState ( {
201+ snapshot : buildSnapshot ( ) ,
202+ policy : {
203+ action : "ask_clarification" ,
204+ committedSlots : { day : "tomorrow" } ,
205+ resolvedContract : contract
206+ } ,
207+ interpretation : {
208+ turnType : "planning_request" ,
209+ confidence : 0.58 ,
210+ resolvedEntityIds : [ ] ,
211+ ambiguity : "high" ,
212+ missingSlots : [ "time" ]
213+ } ,
214+ reply : "What time should I schedule it?" ,
215+ userTurnText : "schedule gym tomorrow" ,
216+ summaryText : null ,
217+ occurredAt : "2026-03-22T16:05:00.000Z"
218+ } ) ;
219+
220+ expect ( result . discourseState ?. pending_write_contract ) . toEqual ( contract ) ;
221+ } ) ;
222+
223+ it ( "does not set pending_write_contract when resolvedContract is absent" , ( ) => {
224+ const result = deriveConversationReplyState ( {
225+ snapshot : buildSnapshot ( ) ,
226+ policy : {
227+ action : "reply_only" ,
228+ committedSlots : { }
229+ } ,
230+ interpretation : {
231+ turnType : "informational" ,
232+ confidence : 0.93 ,
233+ resolvedEntityIds : [ ] ,
234+ ambiguity : "none"
235+ } ,
236+ reply : "Your schedule is empty." ,
237+ userTurnText : "what's on my schedule?" ,
238+ summaryText : null ,
239+ occurredAt : "2026-03-22T16:05:00.000Z"
240+ } ) ;
241+
242+ expect ( result . discourseState ?. pending_write_contract ) . toBeUndefined ( ) ;
243+ } ) ;
196244} ) ;
197245
198246describe ( "deriveMutationState" , ( ) => {
@@ -306,4 +354,126 @@ describe("deriveMutationState", () => {
306354 ] ) ;
307355 expect ( result . discourseState . mode ) . toBe ( "editing" ) ;
308356 } ) ;
357+
358+ it ( "clears resolved_slots and pending_write_contract on successful mutation" , ( ) => {
359+ const snapshot = buildSnapshot ( ) ;
360+ snapshot . discourseState = {
361+ ...snapshot . discourseState ! ,
362+ resolved_slots : { day : "tomorrow" , time : "17:00" } ,
363+ pending_write_contract : { requiredSlots : [ "day" , "time" ] , intentKind : "plan" }
364+ } ;
365+
366+ const processing : ProcessedInboxResult = {
367+ outcome : "planned" ,
368+ inboxItem : {
369+ id : "inbox-1" ,
370+ userId : "user-1" ,
371+ rawText : "schedule gym" ,
372+ normalizedText : "schedule gym" ,
373+ processingStatus : "planned" ,
374+ linkedTaskIds : [ "task-1" ] ,
375+ createdAt : "2026-03-22T16:00:00.000Z"
376+ } ,
377+ plannerRun : {
378+ id : "run-1" ,
379+ userId : "user-1" ,
380+ inboxItemId : "inbox-1" ,
381+ version : "test" ,
382+ modelInput : { } ,
383+ modelOutput : { } ,
384+ confidence : 0.92
385+ } ,
386+ createdTasks : [
387+ {
388+ id : "task-1" ,
389+ userId : "user-1" ,
390+ sourceInboxItemId : "inbox-1" ,
391+ lastInboxItemId : "inbox-1" ,
392+ title : "Gym" ,
393+ lifecycleState : "scheduled" ,
394+ externalCalendarEventId : null ,
395+ externalCalendarId : null ,
396+ scheduledStartAt : "2026-03-23T17:00:00.000Z" ,
397+ scheduledEndAt : "2026-03-23T18:00:00.000Z" ,
398+ calendarSyncStatus : "in_sync" ,
399+ calendarSyncUpdatedAt : null ,
400+ rescheduleCount : 0 ,
401+ lastFollowupAt : null ,
402+ followupReminderSentAt : null ,
403+ completedAt : null ,
404+ archivedAt : null ,
405+ priority : "medium" ,
406+ urgency : "medium"
407+ }
408+ ] ,
409+ scheduleBlocks : [
410+ {
411+ id : "block-1" ,
412+ userId : "user-1" ,
413+ taskId : "task-1" ,
414+ startAt : "2026-03-23T17:00:00.000Z" ,
415+ endAt : "2026-03-23T18:00:00.000Z" ,
416+ confidence : 0.92 ,
417+ reason : "User requested 5 PM." ,
418+ rescheduleCount : 0 ,
419+ externalCalendarId : null
420+ }
421+ ] ,
422+ followUpMessage : "Scheduled gym for 5 PM."
423+ } ;
424+
425+ const result = deriveMutationState ( {
426+ snapshot,
427+ processing,
428+ occurredAt : "2026-03-22T16:10:00.000Z"
429+ } ) ;
430+
431+ expect ( result . discourseState . resolved_slots ) . toEqual ( { } ) ;
432+ expect ( result . discourseState . pending_write_contract ) . toBeUndefined ( ) ;
433+ } ) ;
434+
435+ it ( "preserves resolved_slots and pending_write_contract on needs_clarification" , ( ) => {
436+ const snapshot = buildSnapshot ( ) ;
437+ snapshot . discourseState = {
438+ ...snapshot . discourseState ! ,
439+ resolved_slots : { day : "tomorrow" } ,
440+ pending_write_contract : { requiredSlots : [ "day" , "time" ] , intentKind : "plan" }
441+ } ;
442+
443+ const processing : ProcessedInboxResult = {
444+ outcome : "needs_clarification" ,
445+ inboxItem : {
446+ id : "inbox-1" ,
447+ userId : "user-1" ,
448+ rawText : "schedule gym tomorrow" ,
449+ normalizedText : "schedule gym tomorrow" ,
450+ processingStatus : "needs_clarification" ,
451+ linkedTaskIds : [ ] ,
452+ createdAt : "2026-03-22T16:00:00.000Z"
453+ } ,
454+ plannerRun : {
455+ id : "run-1" ,
456+ userId : "user-1" ,
457+ inboxItemId : "inbox-1" ,
458+ version : "test" ,
459+ modelInput : { } ,
460+ modelOutput : { } ,
461+ confidence : 0.5
462+ } ,
463+ reason : "time" ,
464+ followUpMessage : "What time should I schedule the gym?"
465+ } ;
466+
467+ const result = deriveMutationState ( {
468+ snapshot,
469+ processing,
470+ occurredAt : "2026-03-22T16:10:00.000Z"
471+ } ) ;
472+
473+ expect ( result . discourseState . resolved_slots ) . toEqual ( { day : "tomorrow" } ) ;
474+ expect ( result . discourseState . pending_write_contract ) . toEqual ( {
475+ requiredSlots : [ "day" , "time" ] ,
476+ intentKind : "plan"
477+ } ) ;
478+ } ) ;
309479} ) ;
0 commit comments