@raidiant/notifai 0.2.0 → 0.3.0
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/dist/commands.d.ts +33 -3
- package/dist/commands.js +283 -59
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +62 -0
- package/dist/config-schema.js +274 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/hooks.d.ts +31 -19
- package/dist/hooks.js +277 -189
- package/dist/hooks.js.map +1 -1
- package/dist/interactive.d.ts +4 -0
- package/dist/interactive.js +604 -0
- package/dist/interactive.js.map +1 -0
- package/dist/main.js +106 -17
- package/dist/main.js.map +1 -1
- package/dist/ui/banner.d.ts +22 -0
- package/dist/ui/banner.js +87 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/config-view.d.ts +32 -0
- package/dist/ui/config-view.js +105 -0
- package/dist/ui/config-view.js.map +1 -0
- package/dist/ui/help.d.ts +46 -0
- package/dist/ui/help.js +105 -0
- package/dist/ui/help.js.map +1 -0
- package/dist/ui/theme.d.ts +108 -0
- package/dist/ui/theme.js +261 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +2 -1
- package/src/commands.test.ts +153 -11
- package/src/commands.ts +328 -60
- package/src/config-schema.test.ts +152 -0
- package/src/config-schema.ts +321 -0
- package/src/hooks.test.ts +214 -81
- package/src/hooks.ts +335 -224
- package/src/interactive.ts +692 -0
- package/src/main.ts +107 -16
- package/src/ui/banner.ts +95 -0
- package/src/ui/config-view.test.ts +86 -0
- package/src/ui/config-view.ts +135 -0
- package/src/ui/help.ts +124 -0
- package/src/ui/theme.test.ts +92 -0
- package/src/ui/theme.ts +279 -0
package/src/hooks.test.ts
CHANGED
|
@@ -510,7 +510,7 @@ describe('nagging guards', () => {
|
|
|
510
510
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n4', stop_hook_active: true }))
|
|
511
511
|
|
|
512
512
|
expect(h.recorder.submitted).toEqual([])
|
|
513
|
-
expect(readSessionState('n4', h.env).pending?.question).toBe('One more?')
|
|
513
|
+
expect(readSessionState('n4', h.env).pending?.[0]?.question).toBe('One more?')
|
|
514
514
|
expect(h.io.errLines.join('\n')).toContain('continuation limit (3) reached')
|
|
515
515
|
})
|
|
516
516
|
})
|
|
@@ -523,7 +523,7 @@ describe('late answer collection', () => {
|
|
|
523
523
|
registerQuestion('late-stop', h.env, { question: 'Ship it?' }, NOW)
|
|
524
524
|
|
|
525
525
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
|
|
526
|
-
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')
|
|
527
527
|
answers.push(reply({ text: 'Ship it' }))
|
|
528
528
|
h.io.outLines = []
|
|
529
529
|
|
|
@@ -585,7 +585,7 @@ describe('OpenCode answer preservation', () => {
|
|
|
585
585
|
expect(
|
|
586
586
|
h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired'),
|
|
587
587
|
).toEqual([])
|
|
588
|
-
expect(readSessionState('open1', h.env).pending).toMatchObject({
|
|
588
|
+
expect(readSessionState('open1', h.env).pending?.[0]).toMatchObject({
|
|
589
589
|
question: 'Deploy?',
|
|
590
590
|
request_id: 'req_hook_1',
|
|
591
591
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
@@ -609,7 +609,7 @@ describe('reply-wait presence monitoring', () => {
|
|
|
609
609
|
|
|
610
610
|
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
611
611
|
expect(h.recorder.closed).toEqual([])
|
|
612
|
-
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')
|
|
613
613
|
expect((h.deps.now?.() ?? NOW) - NOW).toBeLessThan(30_000)
|
|
614
614
|
expect(h.io.errLines.join('\n')).toContain('came back')
|
|
615
615
|
expect(h.io.errLines.join('\n')).toContain('notifai replies --pending')
|
|
@@ -643,11 +643,14 @@ describe('Cursor stop output', () => {
|
|
|
643
643
|
})
|
|
644
644
|
|
|
645
645
|
/**
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
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.
|
|
649
652
|
*/
|
|
650
|
-
describe('
|
|
653
|
+
describe('question queueing and retirement debt', () => {
|
|
651
654
|
/** The lifecycle state of each retirement push the recorder captured. */
|
|
652
655
|
function retirements(h: Harness): { state: unknown; retires: unknown }[] {
|
|
653
656
|
return h.recorder.submitted
|
|
@@ -658,7 +661,7 @@ describe('superseding a live question', () => {
|
|
|
658
661
|
}))
|
|
659
662
|
}
|
|
660
663
|
|
|
661
|
-
it('
|
|
664
|
+
it('keeps the first question live and asks the second alongside it', async () => {
|
|
662
665
|
const h = harness([])
|
|
663
666
|
writeSessionState('sup1', h.env, { last_prompt_at: AWAY })
|
|
664
667
|
registerQuestion('sup1', h.env, { question: 'Ship it?' })
|
|
@@ -667,15 +670,19 @@ describe('superseding a live question', () => {
|
|
|
667
670
|
expect(first).toBeDefined()
|
|
668
671
|
|
|
669
672
|
// The agent carried on and asked something else. The first question is
|
|
670
|
-
//
|
|
673
|
+
// still the user's to answer — a second question never ends the first;
|
|
674
|
+
// superseding is reply semantics, not question semantics.
|
|
671
675
|
registerQuestion('sup1', h.env, { question: 'Deploy it?' })
|
|
672
676
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
|
|
673
677
|
|
|
674
|
-
expect(retirements(h)).
|
|
675
|
-
// And the replacement was still asked; superseding is not suppressing.
|
|
678
|
+
expect(retirements(h)).toEqual([])
|
|
676
679
|
expect(
|
|
677
680
|
h.recorder.submitted.filter((s) => s.draft.presentation.body === 'Deploy it?'),
|
|
678
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)
|
|
679
686
|
})
|
|
680
687
|
|
|
681
688
|
it('keeps the ids when the retirement cannot be sent, and retries later', async () => {
|
|
@@ -685,17 +692,15 @@ describe('superseding a live question', () => {
|
|
|
685
692
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
|
|
686
693
|
const first = h.recorder.receipts[0]
|
|
687
694
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
// 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.
|
|
692
697
|
h.recorder.failSubmits = true
|
|
693
|
-
await hookRunCommand(h.deps, '
|
|
698
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup2' }))
|
|
694
699
|
expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
|
|
695
700
|
|
|
696
701
|
h.recorder.failSubmits = false
|
|
697
702
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
|
|
698
|
-
expect(retirements(h)).toContainEqual({ state: '
|
|
703
|
+
expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
|
|
699
704
|
expect(readSessionState('sup2', h.env).retiring).toEqual([])
|
|
700
705
|
})
|
|
701
706
|
|
|
@@ -705,20 +710,19 @@ describe('superseding a live question', () => {
|
|
|
705
710
|
registerQuestion('sup-targets', h.env, { question: 'Ship it?' })
|
|
706
711
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
|
|
707
712
|
|
|
708
|
-
const live = readSessionState('sup-targets', h.env).pending
|
|
713
|
+
const live = readSessionState('sup-targets', h.env).pending?.[0]
|
|
709
714
|
expect(live).toMatchObject({
|
|
710
715
|
request_id: 'req_hook_1',
|
|
711
716
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
712
717
|
})
|
|
713
718
|
|
|
714
|
-
// The user changes their default routing before the
|
|
715
|
-
//
|
|
716
|
-
//
|
|
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.
|
|
717
722
|
const configDir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
718
723
|
mkdirSync(configDir, { recursive: true })
|
|
719
724
|
writeFileSync(path.join(configDir, 'config.toml'), 'devices = ["dev_iphone"]\n')
|
|
720
|
-
|
|
721
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
|
|
725
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup-targets' }))
|
|
722
726
|
|
|
723
727
|
const retirement = h.recorder.submitted.find(
|
|
724
728
|
(submission) => submission.draft.event === 'question_retired',
|
|
@@ -731,18 +735,24 @@ describe('superseding a live question', () => {
|
|
|
731
735
|
})
|
|
732
736
|
|
|
733
737
|
it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
|
|
734
|
-
// stop_hook_active short-circuits the escalation path,
|
|
735
|
-
//
|
|
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.
|
|
736
741
|
const h = harness([])
|
|
737
742
|
writeSessionState('sup3', h.env, { last_prompt_at: AWAY })
|
|
738
743
|
registerQuestion('sup3', h.env, { question: 'Ship it?' })
|
|
739
744
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3' }))
|
|
740
745
|
const first = h.recorder.receipts[0]
|
|
741
746
|
|
|
742
|
-
|
|
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
|
+
|
|
743
752
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3', stop_hook_active: true }))
|
|
744
753
|
|
|
745
|
-
expect(retirements(h)).toContainEqual({ state: '
|
|
754
|
+
expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
|
|
755
|
+
expect(readSessionState('sup3', h.env).retiring).toEqual([])
|
|
746
756
|
})
|
|
747
757
|
|
|
748
758
|
it('does not lose a queued retirement when the user comes back to the terminal', async () => {
|
|
@@ -754,25 +764,123 @@ describe('superseding a live question', () => {
|
|
|
754
764
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup4' }))
|
|
755
765
|
const first = h.recorder.receipts[0]
|
|
756
766
|
|
|
757
|
-
|
|
767
|
+
// The first return is offline: the wipe parks, the drain fails, and the
|
|
768
|
+
// next prompt must still find the debt.
|
|
758
769
|
h.recorder.failSubmits = true
|
|
759
|
-
await hookRunCommand(h.deps, '
|
|
770
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
|
|
760
771
|
h.recorder.failSubmits = false
|
|
761
772
|
|
|
762
773
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
|
|
763
774
|
|
|
764
|
-
expect(retirements(h)).toContainEqual({ state: '
|
|
775
|
+
expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
|
|
765
776
|
expect(readSessionState('sup4', h.env).retiring).toEqual([])
|
|
766
777
|
})
|
|
767
778
|
|
|
768
|
-
it('parks nothing for
|
|
779
|
+
it('parks nothing for questions that never reached a device', async () => {
|
|
769
780
|
// No request_id means there is no notification anywhere to retire, and a
|
|
770
|
-
// retirement push for one would be pure noise.
|
|
781
|
+
// retirement push for one would be pure noise. Both questions simply
|
|
782
|
+
// queue.
|
|
771
783
|
const h = harness([])
|
|
772
784
|
registerQuestion('sup5', h.env, { question: 'Ship it?' })
|
|
773
785
|
registerQuestion('sup5', h.env, { question: 'Deploy it?' })
|
|
774
786
|
|
|
775
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)
|
|
776
884
|
})
|
|
777
885
|
})
|
|
778
886
|
|
|
@@ -815,15 +923,15 @@ describe('a question that outlives its session', () => {
|
|
|
815
923
|
const h = harness([])
|
|
816
924
|
const first = await pushUnanswered(h, 'dead2')
|
|
817
925
|
|
|
818
|
-
//
|
|
819
|
-
|
|
926
|
+
// Died while offline: the user came back, the wipe parked the
|
|
927
|
+
// retirement, and the drain could not send it.
|
|
820
928
|
h.recorder.failSubmits = true
|
|
821
|
-
await hookRunCommand(h.deps, '
|
|
929
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'dead2' }))
|
|
822
930
|
h.recorder.failSubmits = false
|
|
823
931
|
|
|
824
932
|
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead2' }))
|
|
825
933
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next2' }))
|
|
826
|
-
expect(retirements(h)).toContainEqual({ state: '
|
|
934
|
+
expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
|
|
827
935
|
})
|
|
828
936
|
|
|
829
937
|
it('keeps the debt when the drain fails, and drops entries past the TTL', async () => {
|
|
@@ -928,7 +1036,7 @@ describe('ask registration', () => {
|
|
|
928
1036
|
session: 'a2',
|
|
929
1037
|
}),
|
|
930
1038
|
).toBe(EXIT.ok)
|
|
931
|
-
expect(readSessionState('a2', h.env).pending?.questions).toEqual([
|
|
1039
|
+
expect(readSessionState('a2', h.env).pending?.[0]?.questions).toEqual([
|
|
932
1040
|
{
|
|
933
1041
|
id: 'ship-it',
|
|
934
1042
|
text: 'Ship it?',
|
|
@@ -955,7 +1063,7 @@ describe('ask registration', () => {
|
|
|
955
1063
|
}),
|
|
956
1064
|
}),
|
|
957
1065
|
).toBe(EXIT.ok)
|
|
958
|
-
const pending = readSessionState('form1', h.env).pending
|
|
1066
|
+
const pending = readSessionState('form1', h.env).pending?.[0]
|
|
959
1067
|
expect(pending?.questions).toHaveLength(2)
|
|
960
1068
|
expect(pending?.detail).toContain('long story')
|
|
961
1069
|
|
|
@@ -1040,7 +1148,7 @@ describe('ask registration', () => {
|
|
|
1040
1148
|
'claude-code',
|
|
1041
1149
|
)
|
|
1042
1150
|
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
1043
|
-
expect(readSessionState('real1', h.env).pending?.question).toBe('Ship it?')
|
|
1151
|
+
expect(readSessionState('real1', h.env).pending?.[0]?.question).toBe('Ship it?')
|
|
1044
1152
|
expect(readSessionState('my-label', h.env).pending).toBeUndefined()
|
|
1045
1153
|
})
|
|
1046
1154
|
|
|
@@ -1048,24 +1156,42 @@ describe('ask registration', () => {
|
|
|
1048
1156
|
const h = harness()
|
|
1049
1157
|
h.deps.env['NOTIFAI_SESSION'] = 'solo-session'
|
|
1050
1158
|
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
1051
|
-
expect(readSessionState('solo-session', h.env).pending?.question).toBe('Ship it?')
|
|
1159
|
+
expect(readSessionState('solo-session', h.env).pending?.[0]?.question).toBe('Ship it?')
|
|
1052
1160
|
})
|
|
1053
1161
|
|
|
1054
|
-
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.
|
|
1055
1166
|
const h = harness()
|
|
1056
1167
|
writeSessionState('incomplete', h.env, {
|
|
1057
|
-
pending:
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1168
|
+
pending: [
|
|
1169
|
+
{
|
|
1170
|
+
question: 'Original?',
|
|
1171
|
+
request_id: 'req_original',
|
|
1172
|
+
collapse_key: 'collapse-original',
|
|
1173
|
+
},
|
|
1174
|
+
],
|
|
1062
1175
|
})
|
|
1063
1176
|
|
|
1064
|
-
expect(askCommand(h.deps, '
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
)
|
|
1068
|
-
|
|
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)
|
|
1069
1195
|
})
|
|
1070
1196
|
})
|
|
1071
1197
|
|
|
@@ -1097,7 +1223,7 @@ describe('user-prompt-submit hook', () => {
|
|
|
1097
1223
|
expect(h.io.errLines.join('\n')).toMatch(/not paired|credential/i)
|
|
1098
1224
|
})
|
|
1099
1225
|
|
|
1100
|
-
it('preserves a delivered question through ask
|
|
1226
|
+
it('preserves a delivered question through a second ask and the prompt transition', async () => {
|
|
1101
1227
|
const h = harness([], 900)
|
|
1102
1228
|
await hookRunCommand(
|
|
1103
1229
|
h.deps,
|
|
@@ -1108,7 +1234,7 @@ describe('user-prompt-submit hook', () => {
|
|
|
1108
1234
|
expect(askCommand(h.deps, 'Ship it?', { choice: ['Yes', 'No'] })).toBe(EXIT.ok)
|
|
1109
1235
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
|
|
1110
1236
|
|
|
1111
|
-
const live = readSessionState('transition', h.env).pending
|
|
1237
|
+
const live = readSessionState('transition', h.env).pending?.[0]
|
|
1112
1238
|
expect(live).toMatchObject({
|
|
1113
1239
|
question: 'Ship it?',
|
|
1114
1240
|
request_id: 'req_hook_1',
|
|
@@ -1116,19 +1242,14 @@ describe('user-prompt-submit hook', () => {
|
|
|
1116
1242
|
})
|
|
1117
1243
|
expect(live?.collapse_key).toMatch(/^notifai-hook-/)
|
|
1118
1244
|
|
|
1119
|
-
// A second plain `ask` resolves through the project pointer and
|
|
1120
|
-
// the live question
|
|
1121
|
-
// 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.
|
|
1122
1249
|
expect(askCommand(h.deps, 'Deploy it?', { choice: ['Staging', 'Production'] })).toBe(EXIT.ok)
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
collapse_key: live?.collapse_key,
|
|
1127
|
-
device_ids: ['dev_iphone', 'dev_mac'],
|
|
1128
|
-
question: 'Ship it?',
|
|
1129
|
-
state: 'superseded',
|
|
1130
|
-
},
|
|
1131
|
-
])
|
|
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?'])
|
|
1132
1253
|
|
|
1133
1254
|
await hookRunCommand(
|
|
1134
1255
|
h.deps,
|
|
@@ -1137,19 +1258,24 @@ describe('user-prompt-submit hook', () => {
|
|
|
1137
1258
|
'claude-code',
|
|
1138
1259
|
)
|
|
1139
1260
|
|
|
1261
|
+
// The delivered question retires truthfully; the never-delivered second
|
|
1262
|
+
// one has nothing on any device and leaves no trace.
|
|
1140
1263
|
const retirement = h.recorder.submitted.find(
|
|
1141
1264
|
(submission) => submission.draft.event === 'question_retired',
|
|
1142
1265
|
)?.draft
|
|
1143
1266
|
expect(retirement?.delivery.collapse_key).toBe(live?.collapse_key)
|
|
1144
1267
|
expect(retirement?.lifecycle).toEqual({
|
|
1145
1268
|
tier: 'done',
|
|
1146
|
-
state: '
|
|
1269
|
+
state: 'answered_elsewhere',
|
|
1147
1270
|
retires_request_id: live?.request_id,
|
|
1148
1271
|
})
|
|
1149
1272
|
expect(retirement?.targets).toEqual({
|
|
1150
1273
|
mode: 'selected',
|
|
1151
1274
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
1152
1275
|
})
|
|
1276
|
+
expect(
|
|
1277
|
+
h.recorder.submitted.filter((s) => s.draft.event === 'question_retired'),
|
|
1278
|
+
).toHaveLength(1)
|
|
1153
1279
|
})
|
|
1154
1280
|
|
|
1155
1281
|
it('retires a question a real timed-out Stop left live on the devices', async () => {
|
|
@@ -1161,7 +1287,7 @@ describe('user-prompt-submit hook', () => {
|
|
|
1161
1287
|
registerQuestion('s11', h.env, { question: 'Which environment?' })
|
|
1162
1288
|
|
|
1163
1289
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
|
|
1164
|
-
const live = readSessionState('s11', h.env).pending
|
|
1290
|
+
const live = readSessionState('s11', h.env).pending?.[0]
|
|
1165
1291
|
expect(live?.request_id).toBe('req_hook_1')
|
|
1166
1292
|
expect(live?.collapse_key).toBeDefined()
|
|
1167
1293
|
|
|
@@ -1272,16 +1398,18 @@ describe('session-end hook', () => {
|
|
|
1272
1398
|
it('preserves incomplete live state and reports why it cannot retire it', async () => {
|
|
1273
1399
|
const h = harness()
|
|
1274
1400
|
writeSessionState('s-incomplete', h.env, {
|
|
1275
|
-
pending:
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1401
|
+
pending: [
|
|
1402
|
+
{
|
|
1403
|
+
question: 'Still live?',
|
|
1404
|
+
request_id: 'req_incomplete',
|
|
1405
|
+
collapse_key: 'collapse-incomplete',
|
|
1406
|
+
},
|
|
1407
|
+
],
|
|
1280
1408
|
})
|
|
1281
1409
|
|
|
1282
1410
|
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 's-incomplete' }))
|
|
1283
1411
|
|
|
1284
|
-
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')
|
|
1285
1413
|
expect(h.io.errLines.join('\n')).toContain(
|
|
1286
1414
|
'refusing to retire it without request, collapse, and device identifiers',
|
|
1287
1415
|
)
|
|
@@ -1431,7 +1559,7 @@ describe('pruning abandoned session state', () => {
|
|
|
1431
1559
|
it('bounds what one runaway question can write to disk', () => {
|
|
1432
1560
|
const h = harness()
|
|
1433
1561
|
registerQuestion('big', h.env, { question: 'x'.repeat(50_000) })
|
|
1434
|
-
expect(readSessionState('big', h.env).pending?.question.length).toBeLessThanOrEqual(2000)
|
|
1562
|
+
expect(readSessionState('big', h.env).pending?.[0]?.question.length).toBeLessThanOrEqual(2000)
|
|
1435
1563
|
})
|
|
1436
1564
|
})
|
|
1437
1565
|
|
|
@@ -1604,7 +1732,7 @@ describe('two hooks racing one question', () => {
|
|
|
1604
1732
|
})
|
|
1605
1733
|
|
|
1606
1734
|
describe('question registration racing a Stop submission', () => {
|
|
1607
|
-
it('
|
|
1735
|
+
it('keeps both questions when a new ask races the older submit', async () => {
|
|
1608
1736
|
const h = harness([], 900)
|
|
1609
1737
|
writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
|
|
1610
1738
|
registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
|
|
@@ -1615,11 +1743,16 @@ describe('question registration racing a Stop submission', () => {
|
|
|
1615
1743
|
|
|
1616
1744
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'submit-race' }))
|
|
1617
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.
|
|
1618
1748
|
const state = readSessionState('submit-race', h.env)
|
|
1619
|
-
expect(state.pending?.question).
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
)
|
|
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([])
|
|
1623
1756
|
})
|
|
1624
1757
|
|
|
1625
1758
|
it('keeps the newer question when the older in-flight question receives an answer', async () => {
|
|
@@ -1634,7 +1767,7 @@ describe('question registration racing a Stop submission', () => {
|
|
|
1634
1767
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-race' }))
|
|
1635
1768
|
|
|
1636
1769
|
const state = readSessionState('answer-race', h.env)
|
|
1637
|
-
expect(state.pending?.question).
|
|
1770
|
+
expect(state.pending?.map((entry) => entry.question)).toEqual(['New question?'])
|
|
1638
1771
|
expect(
|
|
1639
1772
|
h.recorder.submitted.find(
|
|
1640
1773
|
(entry) =>
|