@raidiant/notifai 0.1.8 → 0.2.1
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/README.md +1 -1
- package/dist/commands.d.ts +30 -12
- package/dist/commands.js +234 -78
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +38 -24
- package/dist/hooks.js +316 -187
- package/dist/hooks.js.map +1 -1
- package/dist/main.js +35 -3
- package/dist/main.js.map +1 -1
- package/dist/send.d.ts +19 -26
- package/dist/send.js +35 -45
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +174 -16
- package/src/commands.ts +270 -80
- package/src/hooks.test.ts +314 -83
- package/src/hooks.ts +389 -223
- package/src/install-hooks.test.ts +8 -15
- package/src/main.ts +41 -4
- package/src/send.ts +50 -48
package/src/hooks.test.ts
CHANGED
|
@@ -127,7 +127,8 @@ function reply(overrides: Partial<ReplyView> = {}): ReplyView {
|
|
|
127
127
|
device_id: 'dev_test',
|
|
128
128
|
device_name: 'Furankuphone',
|
|
129
129
|
text: 'Allow',
|
|
130
|
-
|
|
130
|
+
answers: [{ question_id: 'q1', choice_ids: ['allow'], text: null }],
|
|
131
|
+
source: null,
|
|
131
132
|
created_at: new Date().toISOString(),
|
|
132
133
|
...overrides,
|
|
133
134
|
}
|
|
@@ -509,7 +510,7 @@ describe('nagging guards', () => {
|
|
|
509
510
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n4', stop_hook_active: true }))
|
|
510
511
|
|
|
511
512
|
expect(h.recorder.submitted).toEqual([])
|
|
512
|
-
expect(readSessionState('n4', h.env).pending?.question).toBe('One more?')
|
|
513
|
+
expect(readSessionState('n4', h.env).pending?.[0]?.question).toBe('One more?')
|
|
513
514
|
expect(h.io.errLines.join('\n')).toContain('continuation limit (3) reached')
|
|
514
515
|
})
|
|
515
516
|
})
|
|
@@ -522,7 +523,7 @@ describe('late answer collection', () => {
|
|
|
522
523
|
registerQuestion('late-stop', h.env, { question: 'Ship it?' }, NOW)
|
|
523
524
|
|
|
524
525
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
|
|
525
|
-
expect(readSessionState('late-stop', h.env).pending?.request_id).toBe('req_hook_1')
|
|
526
|
+
expect(readSessionState('late-stop', h.env).pending?.[0]?.request_id).toBe('req_hook_1')
|
|
526
527
|
answers.push(reply({ text: 'Ship it' }))
|
|
527
528
|
h.io.outLines = []
|
|
528
529
|
|
|
@@ -584,7 +585,7 @@ describe('OpenCode answer preservation', () => {
|
|
|
584
585
|
expect(
|
|
585
586
|
h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired'),
|
|
586
587
|
).toEqual([])
|
|
587
|
-
expect(readSessionState('open1', h.env).pending).toMatchObject({
|
|
588
|
+
expect(readSessionState('open1', h.env).pending?.[0]).toMatchObject({
|
|
588
589
|
question: 'Deploy?',
|
|
589
590
|
request_id: 'req_hook_1',
|
|
590
591
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
@@ -608,7 +609,7 @@ describe('reply-wait presence monitoring', () => {
|
|
|
608
609
|
|
|
609
610
|
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
610
611
|
expect(h.recorder.closed).toEqual([])
|
|
611
|
-
expect(readSessionState('returned', h.env).pending?.request_id).toBe('req_hook_1')
|
|
612
|
+
expect(readSessionState('returned', h.env).pending?.[0]?.request_id).toBe('req_hook_1')
|
|
612
613
|
expect((h.deps.now?.() ?? NOW) - NOW).toBeLessThan(30_000)
|
|
613
614
|
expect(h.io.errLines.join('\n')).toContain('came back')
|
|
614
615
|
expect(h.io.errLines.join('\n')).toContain('notifai replies --pending')
|
|
@@ -642,11 +643,14 @@ describe('Cursor stop output', () => {
|
|
|
642
643
|
})
|
|
643
644
|
|
|
644
645
|
/**
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
646
|
+
* Questions never supersede questions — a second ask joins the first, and
|
|
647
|
+
* both reach the user (superseding is reply semantics). What remains from the
|
|
648
|
+
* old supersede model is the retirement debt machinery: a delivered question
|
|
649
|
+
* that dies (the user came back to the terminal) must be truthfully retired
|
|
650
|
+
* even when the network fails at the moment we learn it, because stale
|
|
651
|
+
* questions piling up on a phone teach the user to ignore the surface.
|
|
648
652
|
*/
|
|
649
|
-
describe('
|
|
653
|
+
describe('question queueing and retirement debt', () => {
|
|
650
654
|
/** The lifecycle state of each retirement push the recorder captured. */
|
|
651
655
|
function retirements(h: Harness): { state: unknown; retires: unknown }[] {
|
|
652
656
|
return h.recorder.submitted
|
|
@@ -657,7 +661,7 @@ describe('superseding a live question', () => {
|
|
|
657
661
|
}))
|
|
658
662
|
}
|
|
659
663
|
|
|
660
|
-
it('
|
|
664
|
+
it('keeps the first question live and asks the second alongside it', async () => {
|
|
661
665
|
const h = harness([])
|
|
662
666
|
writeSessionState('sup1', h.env, { last_prompt_at: AWAY })
|
|
663
667
|
registerQuestion('sup1', h.env, { question: 'Ship it?' })
|
|
@@ -666,15 +670,19 @@ describe('superseding a live question', () => {
|
|
|
666
670
|
expect(first).toBeDefined()
|
|
667
671
|
|
|
668
672
|
// The agent carried on and asked something else. The first question is
|
|
669
|
-
//
|
|
673
|
+
// still the user's to answer — a second question never ends the first;
|
|
674
|
+
// superseding is reply semantics, not question semantics.
|
|
670
675
|
registerQuestion('sup1', h.env, { question: 'Deploy it?' })
|
|
671
676
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
|
|
672
677
|
|
|
673
|
-
expect(retirements(h)).
|
|
674
|
-
// And the replacement was still asked; superseding is not suppressing.
|
|
678
|
+
expect(retirements(h)).toEqual([])
|
|
675
679
|
expect(
|
|
676
680
|
h.recorder.submitted.filter((s) => s.draft.presentation.body === 'Deploy it?'),
|
|
677
681
|
).toHaveLength(1)
|
|
682
|
+
const live = readSessionState('sup1', h.env).pending
|
|
683
|
+
expect(live).toHaveLength(2)
|
|
684
|
+
expect(live?.map((entry) => entry.question)).toEqual(['Ship it?', 'Deploy it?'])
|
|
685
|
+
expect(live?.every((entry) => entry.request_id !== undefined)).toBe(true)
|
|
678
686
|
})
|
|
679
687
|
|
|
680
688
|
it('keeps the ids when the retirement cannot be sent, and retries later', async () => {
|
|
@@ -684,17 +692,15 @@ describe('superseding a live question', () => {
|
|
|
684
692
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
|
|
685
693
|
const first = h.recorder.receipts[0]
|
|
686
694
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
// Offline: the drain runs, fails, and must not forget what it was for.
|
|
695
|
+
// Offline when the user returns: the wipe parks the retirement, the
|
|
696
|
+
// drain fails, and it must not forget what it was for.
|
|
691
697
|
h.recorder.failSubmits = true
|
|
692
|
-
await hookRunCommand(h.deps, '
|
|
698
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup2' }))
|
|
693
699
|
expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
|
|
694
700
|
|
|
695
701
|
h.recorder.failSubmits = false
|
|
696
702
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
|
|
697
|
-
expect(retirements(h)).toContainEqual({ state: '
|
|
703
|
+
expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
|
|
698
704
|
expect(readSessionState('sup2', h.env).retiring).toEqual([])
|
|
699
705
|
})
|
|
700
706
|
|
|
@@ -704,20 +710,19 @@ describe('superseding a live question', () => {
|
|
|
704
710
|
registerQuestion('sup-targets', h.env, { question: 'Ship it?' })
|
|
705
711
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
|
|
706
712
|
|
|
707
|
-
const live = readSessionState('sup-targets', h.env).pending
|
|
713
|
+
const live = readSessionState('sup-targets', h.env).pending?.[0]
|
|
708
714
|
expect(live).toMatchObject({
|
|
709
715
|
request_id: 'req_hook_1',
|
|
710
716
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
711
717
|
})
|
|
712
718
|
|
|
713
|
-
// The user changes their default routing before the
|
|
714
|
-
//
|
|
715
|
-
//
|
|
719
|
+
// The user changes their default routing before the question dies.
|
|
720
|
+
// Retirement belongs to the Deliveries that actually carried the
|
|
721
|
+
// question, not to whichever Device Installation is selected now.
|
|
716
722
|
const configDir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
717
723
|
mkdirSync(configDir, { recursive: true })
|
|
718
724
|
writeFileSync(path.join(configDir, 'config.toml'), 'devices = ["dev_iphone"]\n')
|
|
719
|
-
|
|
720
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
|
|
725
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup-targets' }))
|
|
721
726
|
|
|
722
727
|
const retirement = h.recorder.submitted.find(
|
|
723
728
|
(submission) => submission.draft.event === 'question_retired',
|
|
@@ -730,18 +735,24 @@ describe('superseding a live question', () => {
|
|
|
730
735
|
})
|
|
731
736
|
|
|
732
737
|
it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
|
|
733
|
-
// stop_hook_active short-circuits the escalation path,
|
|
734
|
-
//
|
|
738
|
+
// stop_hook_active short-circuits the escalation path, but the drain runs
|
|
739
|
+
// before every guard — retirement debt has nothing to do with whether
|
|
740
|
+
// this turn may ask.
|
|
735
741
|
const h = harness([])
|
|
736
742
|
writeSessionState('sup3', h.env, { last_prompt_at: AWAY })
|
|
737
743
|
registerQuestion('sup3', h.env, { question: 'Ship it?' })
|
|
738
744
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3' }))
|
|
739
745
|
const first = h.recorder.receipts[0]
|
|
740
746
|
|
|
741
|
-
|
|
747
|
+
h.recorder.failSubmits = true
|
|
748
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup3' }))
|
|
749
|
+
expect(readSessionState('sup3', h.env).retiring).toHaveLength(1)
|
|
750
|
+
h.recorder.failSubmits = false
|
|
751
|
+
|
|
742
752
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3', stop_hook_active: true }))
|
|
743
753
|
|
|
744
|
-
expect(retirements(h)).toContainEqual({ state: '
|
|
754
|
+
expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
|
|
755
|
+
expect(readSessionState('sup3', h.env).retiring).toEqual([])
|
|
745
756
|
})
|
|
746
757
|
|
|
747
758
|
it('does not lose a queued retirement when the user comes back to the terminal', async () => {
|
|
@@ -753,25 +764,123 @@ describe('superseding a live question', () => {
|
|
|
753
764
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup4' }))
|
|
754
765
|
const first = h.recorder.receipts[0]
|
|
755
766
|
|
|
756
|
-
|
|
767
|
+
// The first return is offline: the wipe parks, the drain fails, and the
|
|
768
|
+
// next prompt must still find the debt.
|
|
757
769
|
h.recorder.failSubmits = true
|
|
758
|
-
await hookRunCommand(h.deps, '
|
|
770
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
|
|
759
771
|
h.recorder.failSubmits = false
|
|
760
772
|
|
|
761
773
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
|
|
762
774
|
|
|
763
|
-
expect(retirements(h)).toContainEqual({ state: '
|
|
775
|
+
expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
|
|
764
776
|
expect(readSessionState('sup4', h.env).retiring).toEqual([])
|
|
765
777
|
})
|
|
766
778
|
|
|
767
|
-
it('parks nothing for
|
|
779
|
+
it('parks nothing for questions that never reached a device', async () => {
|
|
768
780
|
// No request_id means there is no notification anywhere to retire, and a
|
|
769
|
-
// retirement push for one would be pure noise.
|
|
781
|
+
// retirement push for one would be pure noise. Both questions simply
|
|
782
|
+
// queue.
|
|
770
783
|
const h = harness([])
|
|
771
784
|
registerQuestion('sup5', h.env, { question: 'Ship it?' })
|
|
772
785
|
registerQuestion('sup5', h.env, { question: 'Deploy it?' })
|
|
773
786
|
|
|
774
787
|
expect(readSessionState('sup5', h.env).retiring ?? []).toEqual([])
|
|
788
|
+
expect(readSessionState('sup5', h.env).pending?.map((entry) => entry.question)).toEqual([
|
|
789
|
+
'Ship it?',
|
|
790
|
+
'Deploy it?',
|
|
791
|
+
])
|
|
792
|
+
})
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
describe('several questions in flight', () => {
|
|
796
|
+
/** Route each live request its own replies; the shared list cannot say who answered what. */
|
|
797
|
+
function repliesByRequest(h: Harness, byRequest: Map<string, ReplyView[]>): void {
|
|
798
|
+
const factory = h.deps.clientFactory
|
|
799
|
+
h.deps.clientFactory = () => {
|
|
800
|
+
const client = factory!()
|
|
801
|
+
return {
|
|
802
|
+
...client,
|
|
803
|
+
replies: async (requestId: string) => ({
|
|
804
|
+
request_id: requestId,
|
|
805
|
+
reply_expires_at: null,
|
|
806
|
+
replies: byRequest.get(requestId) ?? [],
|
|
807
|
+
}),
|
|
808
|
+
} as ApiClient
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
it('escalates every registered question in one pass, each as its own notification', async () => {
|
|
813
|
+
const h = harness([], 900)
|
|
814
|
+
writeSessionState('multi1', h.env, { last_prompt_at: AWAY })
|
|
815
|
+
registerQuestion('multi1', h.env, { question: 'Ship it?' }, NOW)
|
|
816
|
+
registerQuestion('multi1', h.env, { question: 'Deploy where?' }, NOW + 1)
|
|
817
|
+
|
|
818
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi1' }))
|
|
819
|
+
|
|
820
|
+
const questions = h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')
|
|
821
|
+
expect(questions.map((s) => s.draft.presentation.body)).toEqual(['Ship it?', 'Deploy where?'])
|
|
822
|
+
// Each is its own notification with its own collapse key — one ask never
|
|
823
|
+
// stands in for, or replaces, another.
|
|
824
|
+
expect(new Set(questions.map((s) => s.draft.delivery.collapse_key)).size).toBe(2)
|
|
825
|
+
const live = readSessionState('multi1', h.env).pending
|
|
826
|
+
expect(live?.map((entry) => entry.request_id)).toEqual(['req_hook_1', 'req_hook_2'])
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
it('resumes with every answer that arrived, each tied to its question', async () => {
|
|
830
|
+
const h = harness([], 900)
|
|
831
|
+
repliesByRequest(
|
|
832
|
+
h,
|
|
833
|
+
new Map([
|
|
834
|
+
['req_hook_1', [reply({ text: 'Ship it' })]],
|
|
835
|
+
['req_hook_2', [reply({ text: 'Staging' })]],
|
|
836
|
+
]),
|
|
837
|
+
)
|
|
838
|
+
writeSessionState('multi2', h.env, { last_prompt_at: AWAY })
|
|
839
|
+
registerQuestion('multi2', h.env, { question: 'Ship it?' }, NOW)
|
|
840
|
+
registerQuestion('multi2', h.env, { question: 'Deploy where?' }, NOW + 1)
|
|
841
|
+
|
|
842
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi2' }))
|
|
843
|
+
|
|
844
|
+
// Both reply windows close immediately (first answer claims each
|
|
845
|
+
// question); the retirement drain closes again, which is idempotent.
|
|
846
|
+
expect(new Set(h.recorder.closed)).toEqual(new Set(['req_hook_1', 'req_hook_2']))
|
|
847
|
+
expect(readSessionState('multi2', h.env).pending).toBeUndefined()
|
|
848
|
+
const output = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
|
|
849
|
+
expect(output.decision).toBe('block')
|
|
850
|
+
expect(output.reason).toContain('answered 2 questions')
|
|
851
|
+
expect(output.reason).toContain('"Ship it?" → "Ship it"')
|
|
852
|
+
expect(output.reason).toContain('"Deploy where?" → "Staging"')
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
it('resumes with a partial answer and keeps the rest registered', async () => {
|
|
856
|
+
const h = harness([], 900)
|
|
857
|
+
const byRequest = new Map([['req_hook_1', [reply({ text: 'Ship it' })]]])
|
|
858
|
+
repliesByRequest(h, byRequest)
|
|
859
|
+
writeSessionState('multi3', h.env, { last_prompt_at: AWAY })
|
|
860
|
+
registerQuestion('multi3', h.env, { question: 'Ship it?' }, NOW)
|
|
861
|
+
registerQuestion('multi3', h.env, { question: 'Deploy where?' }, NOW + 1)
|
|
862
|
+
|
|
863
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi3' }))
|
|
864
|
+
|
|
865
|
+
const output = JSON.parse(h.io.outLines[0] ?? '{}') as { reason?: string }
|
|
866
|
+
expect(output.reason).toContain('Ship it')
|
|
867
|
+
expect(output.reason).toContain('1 more registered question is still waiting')
|
|
868
|
+
const live = readSessionState('multi3', h.env).pending
|
|
869
|
+
expect(live?.map((entry) => entry.question)).toEqual(['Deploy where?'])
|
|
870
|
+
expect(live?.[0]?.request_id).toBe('req_hook_2')
|
|
871
|
+
|
|
872
|
+
// The remaining question's answer arrives before the next turn ends; the
|
|
873
|
+
// late-answer path hands it over without asking anything twice.
|
|
874
|
+
byRequest.set('req_hook_2', [reply({ text: 'Staging' })])
|
|
875
|
+
h.io.outLines = []
|
|
876
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi3', stop_hook_active: true }))
|
|
877
|
+
|
|
878
|
+
const followup = JSON.parse(h.io.outLines[0] ?? '{}') as { reason?: string }
|
|
879
|
+
expect(followup.reason).toContain('Staging')
|
|
880
|
+
expect(readSessionState('multi3', h.env).pending).toBeUndefined()
|
|
881
|
+
expect(
|
|
882
|
+
h.recorder.submitted.filter((s) => s.draft.event === 'agent_question'),
|
|
883
|
+
).toHaveLength(2)
|
|
775
884
|
})
|
|
776
885
|
})
|
|
777
886
|
|
|
@@ -814,15 +923,15 @@ describe('a question that outlives its session', () => {
|
|
|
814
923
|
const h = harness([])
|
|
815
924
|
const first = await pushUnanswered(h, 'dead2')
|
|
816
925
|
|
|
817
|
-
//
|
|
818
|
-
|
|
926
|
+
// Died while offline: the user came back, the wipe parked the
|
|
927
|
+
// retirement, and the drain could not send it.
|
|
819
928
|
h.recorder.failSubmits = true
|
|
820
|
-
await hookRunCommand(h.deps, '
|
|
929
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'dead2' }))
|
|
821
930
|
h.recorder.failSubmits = false
|
|
822
931
|
|
|
823
932
|
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead2' }))
|
|
824
933
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next2' }))
|
|
825
|
-
expect(retirements(h)).toContainEqual({ state: '
|
|
934
|
+
expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
|
|
826
935
|
})
|
|
827
936
|
|
|
828
937
|
it('keeps the debt when the drain fails, and drops entries past the TTL', async () => {
|
|
@@ -915,11 +1024,11 @@ describe('ask registration', () => {
|
|
|
915
1024
|
const h = harness()
|
|
916
1025
|
// Inside a hook, a rejection is only a stderr note the agent never reads —
|
|
917
1026
|
// so it would look registered and then silently never ask.
|
|
918
|
-
expect(askCommand(h.deps, 'Ship it?', { choice: '
|
|
1027
|
+
expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'], session: 'a1' })).toBe(EXIT.usage)
|
|
919
1028
|
expect(readSessionState('a1', h.env).pending).toBeUndefined()
|
|
920
1029
|
})
|
|
921
1030
|
|
|
922
|
-
it('stores validated
|
|
1031
|
+
it('stores the validated question set, comma-bearing labels verbatim', () => {
|
|
923
1032
|
const h = harness()
|
|
924
1033
|
expect(
|
|
925
1034
|
askCommand(h.deps, 'Ship it?', {
|
|
@@ -927,7 +1036,104 @@ describe('ask registration', () => {
|
|
|
927
1036
|
session: 'a2',
|
|
928
1037
|
}),
|
|
929
1038
|
).toBe(EXIT.ok)
|
|
930
|
-
expect(readSessionState('a2', h.env).pending?.
|
|
1039
|
+
expect(readSessionState('a2', h.env).pending?.[0]?.questions).toEqual([
|
|
1040
|
+
{
|
|
1041
|
+
id: 'ship-it',
|
|
1042
|
+
text: 'Ship it?',
|
|
1043
|
+
choices: [
|
|
1044
|
+
{ id: 'yes-ship-it', label: 'Yes, ship it' },
|
|
1045
|
+
{ id: 'no-hold', label: 'No, hold' },
|
|
1046
|
+
],
|
|
1047
|
+
},
|
|
1048
|
+
])
|
|
1049
|
+
})
|
|
1050
|
+
|
|
1051
|
+
it('registers a multi-question form and pushes it as one set', async () => {
|
|
1052
|
+
const h = harness([reply({ text: 'Yes' })], 900)
|
|
1053
|
+
writeSessionState('form1', h.env, { last_prompt_at: AWAY })
|
|
1054
|
+
expect(
|
|
1055
|
+
askCommand(h.deps, undefined, {
|
|
1056
|
+
session: 'form1',
|
|
1057
|
+
form: JSON.stringify({
|
|
1058
|
+
questions: [
|
|
1059
|
+
{ text: 'Deploy where?', choices: ['Staging', 'Production'], multi: true },
|
|
1060
|
+
{ text: 'Anything to watch?' },
|
|
1061
|
+
],
|
|
1062
|
+
detail: '## Context\nThe long story.',
|
|
1063
|
+
}),
|
|
1064
|
+
}),
|
|
1065
|
+
).toBe(EXIT.ok)
|
|
1066
|
+
const pending = readSessionState('form1', h.env).pending?.[0]
|
|
1067
|
+
expect(pending?.questions).toHaveLength(2)
|
|
1068
|
+
expect(pending?.detail).toContain('long story')
|
|
1069
|
+
|
|
1070
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'form1' }))
|
|
1071
|
+
const draft = h.recorder.submitted[0]?.draft
|
|
1072
|
+
// The banner leads with the first question and admits the rest exists;
|
|
1073
|
+
// the full set rides the payload for the answering card.
|
|
1074
|
+
expect(draft?.presentation.body).toBe('Deploy where? (+1 more)')
|
|
1075
|
+
expect(draft?.presentation.detail).toContain('long story')
|
|
1076
|
+
expect(draft?.reply?.questions).toEqual([
|
|
1077
|
+
{
|
|
1078
|
+
id: 'deploy-where',
|
|
1079
|
+
text: 'Deploy where?',
|
|
1080
|
+
choices: [
|
|
1081
|
+
{ id: 'staging', label: 'Staging' },
|
|
1082
|
+
{ id: 'production', label: 'Production' },
|
|
1083
|
+
],
|
|
1084
|
+
multi: true,
|
|
1085
|
+
},
|
|
1086
|
+
{ id: 'anything-to-watch', text: 'Anything to watch?' },
|
|
1087
|
+
])
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
it('acts on the latest reply when answers conflict, because it is a correction', async () => {
|
|
1091
|
+
const h = harness(
|
|
1092
|
+
[
|
|
1093
|
+
reply({ seq: 1, text: 'Yes', answers: [{ question_id: 'ship-it', choice_ids: ['yes'], text: null }] }),
|
|
1094
|
+
reply({
|
|
1095
|
+
seq: 2,
|
|
1096
|
+
reply_id: 'rpl_2',
|
|
1097
|
+
text: 'No',
|
|
1098
|
+
answers: [{ question_id: 'ship-it', choice_ids: ['no'], text: null }],
|
|
1099
|
+
}),
|
|
1100
|
+
],
|
|
1101
|
+
900,
|
|
1102
|
+
)
|
|
1103
|
+
writeSessionState('latest1', h.env, { last_prompt_at: AWAY })
|
|
1104
|
+
expect(
|
|
1105
|
+
askCommand(h.deps, 'Ship it?', { session: 'latest1', choice: ['Yes', 'No'] }),
|
|
1106
|
+
).toBe(EXIT.ok)
|
|
1107
|
+
|
|
1108
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latest1' }))
|
|
1109
|
+
|
|
1110
|
+
const decision = JSON.parse(h.io.outLines.at(-1)!) as { decision: string; reason: string }
|
|
1111
|
+
expect(decision.decision).toBe('block')
|
|
1112
|
+
expect(decision.reason).toContain('"No"')
|
|
1113
|
+
expect(decision.reason).not.toContain('"Yes"')
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
it('hands a free-text answer written in parts to the agent in order', async () => {
|
|
1117
|
+
const h = harness(
|
|
1118
|
+
[
|
|
1119
|
+
reply({ seq: 1, text: 'Use blue', answers: [{ question_id: 'which-color', choice_ids: [], text: 'Use blue' }] }),
|
|
1120
|
+
reply({
|
|
1121
|
+
seq: 2,
|
|
1122
|
+
reply_id: 'rpl_2',
|
|
1123
|
+
text: 'actually teal',
|
|
1124
|
+
answers: [{ question_id: 'which-color', choice_ids: [], text: 'actually teal' }],
|
|
1125
|
+
}),
|
|
1126
|
+
],
|
|
1127
|
+
900,
|
|
1128
|
+
)
|
|
1129
|
+
writeSessionState('parts1', h.env, { last_prompt_at: AWAY })
|
|
1130
|
+
expect(askCommand(h.deps, 'Which color?', { session: 'parts1' })).toBe(EXIT.ok)
|
|
1131
|
+
|
|
1132
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'parts1' }))
|
|
1133
|
+
|
|
1134
|
+
const decision = JSON.parse(h.io.outLines.at(-1)!) as { reason: string }
|
|
1135
|
+
expect(decision.reason).toContain('in the order written')
|
|
1136
|
+
expect(decision.reason).toContain('"Use blue", then "actually teal"')
|
|
931
1137
|
})
|
|
932
1138
|
|
|
933
1139
|
it('resolves the session as flag, then hook pointer, then NOTIFAI_SESSION', async () => {
|
|
@@ -942,7 +1148,7 @@ describe('ask registration', () => {
|
|
|
942
1148
|
'claude-code',
|
|
943
1149
|
)
|
|
944
1150
|
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
945
|
-
expect(readSessionState('real1', h.env).pending?.question).toBe('Ship it?')
|
|
1151
|
+
expect(readSessionState('real1', h.env).pending?.[0]?.question).toBe('Ship it?')
|
|
946
1152
|
expect(readSessionState('my-label', h.env).pending).toBeUndefined()
|
|
947
1153
|
})
|
|
948
1154
|
|
|
@@ -950,24 +1156,42 @@ describe('ask registration', () => {
|
|
|
950
1156
|
const h = harness()
|
|
951
1157
|
h.deps.env['NOTIFAI_SESSION'] = 'solo-session'
|
|
952
1158
|
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
953
|
-
expect(readSessionState('solo-session', h.env).pending?.question).toBe('Ship it?')
|
|
1159
|
+
expect(readSessionState('solo-session', h.env).pending?.[0]?.question).toBe('Ship it?')
|
|
954
1160
|
})
|
|
955
1161
|
|
|
956
|
-
it('
|
|
1162
|
+
it('queues alongside incomplete live state without touching it', () => {
|
|
1163
|
+
// Registering never retires, so even a live entry too incomplete to
|
|
1164
|
+
// retire is no obstacle — it stays exactly as found, and the new
|
|
1165
|
+
// question joins the queue behind it.
|
|
957
1166
|
const h = harness()
|
|
958
1167
|
writeSessionState('incomplete', h.env, {
|
|
959
|
-
pending:
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1168
|
+
pending: [
|
|
1169
|
+
{
|
|
1170
|
+
question: 'Original?',
|
|
1171
|
+
request_id: 'req_original',
|
|
1172
|
+
collapse_key: 'collapse-original',
|
|
1173
|
+
},
|
|
1174
|
+
],
|
|
964
1175
|
})
|
|
965
1176
|
|
|
966
|
-
expect(askCommand(h.deps, '
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
)
|
|
970
|
-
|
|
1177
|
+
expect(askCommand(h.deps, 'Another?', { session: 'incomplete' })).toBe(EXIT.ok)
|
|
1178
|
+
const pending = readSessionState('incomplete', h.env).pending
|
|
1179
|
+
expect(pending?.map((entry) => entry.question)).toEqual(['Original?', 'Another?'])
|
|
1180
|
+
expect(pending?.[0]).toMatchObject({
|
|
1181
|
+
request_id: 'req_original',
|
|
1182
|
+
collapse_key: 'collapse-original',
|
|
1183
|
+
})
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
it('refuses a fifth question and names the form alternative', () => {
|
|
1187
|
+
const h = harness()
|
|
1188
|
+
for (const question of ['One?', 'Two?', 'Three?', 'Four?']) {
|
|
1189
|
+
expect(askCommand(h.deps, question, { session: 'crowded' })).toBe(EXIT.ok)
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
expect(askCommand(h.deps, 'Five?', { session: 'crowded' })).toBe(EXIT.failed)
|
|
1193
|
+
expect(h.io.errLines.join('\n')).toContain('ask --form')
|
|
1194
|
+
expect(readSessionState('crowded', h.env).pending).toHaveLength(4)
|
|
971
1195
|
})
|
|
972
1196
|
})
|
|
973
1197
|
|
|
@@ -999,7 +1223,7 @@ describe('user-prompt-submit hook', () => {
|
|
|
999
1223
|
expect(h.io.errLines.join('\n')).toMatch(/not paired|credential/i)
|
|
1000
1224
|
})
|
|
1001
1225
|
|
|
1002
|
-
it('preserves a delivered question through ask
|
|
1226
|
+
it('preserves a delivered question through a second ask and the prompt transition', async () => {
|
|
1003
1227
|
const h = harness([], 900)
|
|
1004
1228
|
await hookRunCommand(
|
|
1005
1229
|
h.deps,
|
|
@@ -1010,7 +1234,7 @@ describe('user-prompt-submit hook', () => {
|
|
|
1010
1234
|
expect(askCommand(h.deps, 'Ship it?', { choice: ['Yes', 'No'] })).toBe(EXIT.ok)
|
|
1011
1235
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
|
|
1012
1236
|
|
|
1013
|
-
const live = readSessionState('transition', h.env).pending
|
|
1237
|
+
const live = readSessionState('transition', h.env).pending?.[0]
|
|
1014
1238
|
expect(live).toMatchObject({
|
|
1015
1239
|
question: 'Ship it?',
|
|
1016
1240
|
request_id: 'req_hook_1',
|
|
@@ -1018,19 +1242,14 @@ describe('user-prompt-submit hook', () => {
|
|
|
1018
1242
|
})
|
|
1019
1243
|
expect(live?.collapse_key).toMatch(/^notifai-hook-/)
|
|
1020
1244
|
|
|
1021
|
-
// A second plain `ask` resolves through the project pointer and
|
|
1022
|
-
// the live question
|
|
1023
|
-
// Stop can run — the transition that
|
|
1245
|
+
// A second plain `ask` resolves through the project pointer and joins the
|
|
1246
|
+
// queue behind the live question — it must not touch it. The next prompt
|
|
1247
|
+
// then resets presence before another Stop can run — the transition that
|
|
1248
|
+
// used to erase the retirement debt.
|
|
1024
1249
|
expect(askCommand(h.deps, 'Deploy it?', { choice: ['Staging', 'Production'] })).toBe(EXIT.ok)
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
collapse_key: live?.collapse_key,
|
|
1029
|
-
device_ids: ['dev_iphone', 'dev_mac'],
|
|
1030
|
-
question: 'Ship it?',
|
|
1031
|
-
state: 'superseded',
|
|
1032
|
-
},
|
|
1033
|
-
])
|
|
1250
|
+
const queued = readSessionState('transition', h.env)
|
|
1251
|
+
expect(queued.retiring ?? []).toEqual([])
|
|
1252
|
+
expect(queued.pending?.map((entry) => entry.question)).toEqual(['Ship it?', 'Deploy it?'])
|
|
1034
1253
|
|
|
1035
1254
|
await hookRunCommand(
|
|
1036
1255
|
h.deps,
|
|
@@ -1039,19 +1258,24 @@ describe('user-prompt-submit hook', () => {
|
|
|
1039
1258
|
'claude-code',
|
|
1040
1259
|
)
|
|
1041
1260
|
|
|
1261
|
+
// The delivered question retires truthfully; the never-delivered second
|
|
1262
|
+
// one has nothing on any device and leaves no trace.
|
|
1042
1263
|
const retirement = h.recorder.submitted.find(
|
|
1043
1264
|
(submission) => submission.draft.event === 'question_retired',
|
|
1044
1265
|
)?.draft
|
|
1045
1266
|
expect(retirement?.delivery.collapse_key).toBe(live?.collapse_key)
|
|
1046
1267
|
expect(retirement?.lifecycle).toEqual({
|
|
1047
1268
|
tier: 'done',
|
|
1048
|
-
state: '
|
|
1269
|
+
state: 'answered_elsewhere',
|
|
1049
1270
|
retires_request_id: live?.request_id,
|
|
1050
1271
|
})
|
|
1051
1272
|
expect(retirement?.targets).toEqual({
|
|
1052
1273
|
mode: 'selected',
|
|
1053
1274
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
1054
1275
|
})
|
|
1276
|
+
expect(
|
|
1277
|
+
h.recorder.submitted.filter((s) => s.draft.event === 'question_retired'),
|
|
1278
|
+
).toHaveLength(1)
|
|
1055
1279
|
})
|
|
1056
1280
|
|
|
1057
1281
|
it('retires a question a real timed-out Stop left live on the devices', async () => {
|
|
@@ -1063,7 +1287,7 @@ describe('user-prompt-submit hook', () => {
|
|
|
1063
1287
|
registerQuestion('s11', h.env, { question: 'Which environment?' })
|
|
1064
1288
|
|
|
1065
1289
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
|
|
1066
|
-
const live = readSessionState('s11', h.env).pending
|
|
1290
|
+
const live = readSessionState('s11', h.env).pending?.[0]
|
|
1067
1291
|
expect(live?.request_id).toBe('req_hook_1')
|
|
1068
1292
|
expect(live?.collapse_key).toBeDefined()
|
|
1069
1293
|
|
|
@@ -1174,16 +1398,18 @@ describe('session-end hook', () => {
|
|
|
1174
1398
|
it('preserves incomplete live state and reports why it cannot retire it', async () => {
|
|
1175
1399
|
const h = harness()
|
|
1176
1400
|
writeSessionState('s-incomplete', h.env, {
|
|
1177
|
-
pending:
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1401
|
+
pending: [
|
|
1402
|
+
{
|
|
1403
|
+
question: 'Still live?',
|
|
1404
|
+
request_id: 'req_incomplete',
|
|
1405
|
+
collapse_key: 'collapse-incomplete',
|
|
1406
|
+
},
|
|
1407
|
+
],
|
|
1182
1408
|
})
|
|
1183
1409
|
|
|
1184
1410
|
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 's-incomplete' }))
|
|
1185
1411
|
|
|
1186
|
-
expect(readSessionState('s-incomplete', h.env).pending?.request_id).toBe('req_incomplete')
|
|
1412
|
+
expect(readSessionState('s-incomplete', h.env).pending?.[0]?.request_id).toBe('req_incomplete')
|
|
1187
1413
|
expect(h.io.errLines.join('\n')).toContain(
|
|
1188
1414
|
'refusing to retire it without request, collapse, and device identifiers',
|
|
1189
1415
|
)
|
|
@@ -1333,7 +1559,7 @@ describe('pruning abandoned session state', () => {
|
|
|
1333
1559
|
it('bounds what one runaway question can write to disk', () => {
|
|
1334
1560
|
const h = harness()
|
|
1335
1561
|
registerQuestion('big', h.env, { question: 'x'.repeat(50_000) })
|
|
1336
|
-
expect(readSessionState('big', h.env).pending?.question.length).toBeLessThanOrEqual(2000)
|
|
1562
|
+
expect(readSessionState('big', h.env).pending?.[0]?.question.length).toBeLessThanOrEqual(2000)
|
|
1337
1563
|
})
|
|
1338
1564
|
})
|
|
1339
1565
|
|
|
@@ -1506,7 +1732,7 @@ describe('two hooks racing one question', () => {
|
|
|
1506
1732
|
})
|
|
1507
1733
|
|
|
1508
1734
|
describe('question registration racing a Stop submission', () => {
|
|
1509
|
-
it('
|
|
1735
|
+
it('keeps both questions when a new ask races the older submit', async () => {
|
|
1510
1736
|
const h = harness([], 900)
|
|
1511
1737
|
writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
|
|
1512
1738
|
registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
|
|
@@ -1517,11 +1743,16 @@ describe('question registration racing a Stop submission', () => {
|
|
|
1517
1743
|
|
|
1518
1744
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'submit-race' }))
|
|
1519
1745
|
|
|
1746
|
+
// The racing ask appended; the in-flight submit still finds its own entry
|
|
1747
|
+
// and records the delivery ids on it. Nobody retires anybody.
|
|
1520
1748
|
const state = readSessionState('submit-race', h.env)
|
|
1521
|
-
expect(state.pending?.question).
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
)
|
|
1749
|
+
expect(state.pending?.map((entry) => entry.question)).toEqual([
|
|
1750
|
+
'Old question?',
|
|
1751
|
+
'New question?',
|
|
1752
|
+
])
|
|
1753
|
+
expect(state.pending?.[0]?.request_id).toBe('req_hook_1')
|
|
1754
|
+
expect(state.pending?.[1]?.request_id).toBeUndefined()
|
|
1755
|
+
expect(state.retiring ?? []).toEqual([])
|
|
1525
1756
|
})
|
|
1526
1757
|
|
|
1527
1758
|
it('keeps the newer question when the older in-flight question receives an answer', async () => {
|
|
@@ -1536,7 +1767,7 @@ describe('question registration racing a Stop submission', () => {
|
|
|
1536
1767
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-race' }))
|
|
1537
1768
|
|
|
1538
1769
|
const state = readSessionState('answer-race', h.env)
|
|
1539
|
-
expect(state.pending?.question).
|
|
1770
|
+
expect(state.pending?.map((entry) => entry.question)).toEqual(['New question?'])
|
|
1540
1771
|
expect(
|
|
1541
1772
|
h.recorder.submitted.find(
|
|
1542
1773
|
(entry) =>
|