@raidiant/notifai 7.0.2 → 8.0.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/CHANGELOG.md +34 -0
- package/dist/commands-devices.d.ts +2 -1
- package/dist/commands-devices.js +20 -8
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.js +14 -12
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +23 -0
- package/dist/commands-guidance.js +128 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +7 -1
- package/dist/commands-hooks.js +3 -6
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +25 -22
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +5 -3
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +45 -28
- package/dist/commands-send.js.map +1 -1
- package/dist/commands.d.ts +1 -0
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +6 -15
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +1 -3
- package/dist/config.js +0 -3
- package/dist/config.js.map +1 -1
- package/dist/guidance-content.d.ts +30 -0
- package/dist/guidance-content.js +175 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +48 -0
- package/dist/guidance.js +118 -0
- package/dist/guidance.js.map +1 -0
- package/dist/hooks.js +7 -7
- package/dist/hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +13 -8
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +3 -474
- package/dist/main.js.map +1 -1
- package/dist/program.d.ts +49 -0
- package/dist/program.js +571 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +4 -9
- package/dist/send.js +48 -28
- package/dist/send.js.map +1 -1
- package/dist/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- package/package.json +5 -3
- package/src/commands-devices.ts +28 -9
- package/src/commands-doctor.ts +17 -12
- package/src/commands-guidance.ts +170 -0
- package/src/commands-hooks.ts +10 -8
- package/src/commands-init.ts +31 -22
- package/src/commands-send-support.ts +5 -3
- package/src/commands-send.ts +50 -28
- package/src/commands.test.ts +374 -63
- package/src/commands.ts +5 -0
- package/src/config-schema.ts +6 -17
- package/src/config.test.ts +56 -1
- package/src/config.ts +0 -5
- package/src/guidance-content.ts +190 -0
- package/src/guidance.test.ts +268 -0
- package/src/guidance.ts +151 -0
- package/src/hooks.test.ts +37 -5
- package/src/hooks.ts +7 -7
- package/src/interactive.ts +13 -8
- package/src/main.ts +4 -600
- package/src/program.test.ts +202 -0
- package/src/program.ts +723 -0
- package/src/send.ts +53 -32
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +37 -12
package/src/commands.test.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
configUnsetCommand,
|
|
43
43
|
contradictingAnswer,
|
|
44
44
|
describeHookFailure,
|
|
45
|
+
devicesCommand,
|
|
45
46
|
doctorCommand,
|
|
46
47
|
EXIT,
|
|
47
48
|
hooksInstallCommand,
|
|
@@ -129,6 +130,17 @@ const currentCompatibility: CompatibilityResponse = {
|
|
|
129
130
|
replacement_available: false,
|
|
130
131
|
rollout_complete: false,
|
|
131
132
|
},
|
|
133
|
+
{
|
|
134
|
+
platform: 'android',
|
|
135
|
+
recommended_version: null,
|
|
136
|
+
recommended_build: null,
|
|
137
|
+
minimum_receive_build: null,
|
|
138
|
+
minimum_answer_build: null,
|
|
139
|
+
deprecation: null,
|
|
140
|
+
sunset: null,
|
|
141
|
+
replacement_available: false,
|
|
142
|
+
rollout_complete: false,
|
|
143
|
+
},
|
|
132
144
|
],
|
|
133
145
|
server_capabilities: ['answer', 'agent_acknowledgement'],
|
|
134
146
|
}
|
|
@@ -453,6 +465,93 @@ describe('command contracts', () => {
|
|
|
453
465
|
expect(io.outLines[0]).toBe('macos capability contract v1 (payload limit 4096 bytes)')
|
|
454
466
|
})
|
|
455
467
|
|
|
468
|
+
it('passes the selected Android platform through to the capability client', async () => {
|
|
469
|
+
const io = new CapturedIo()
|
|
470
|
+
let requestedPlatform: string | undefined
|
|
471
|
+
const document = CAPABILITIES_V1.describe('android')!
|
|
472
|
+
const client = {
|
|
473
|
+
capabilities: async (platform?: string) => {
|
|
474
|
+
requestedPlatform = platform
|
|
475
|
+
return document
|
|
476
|
+
},
|
|
477
|
+
} as unknown as ApiClient
|
|
478
|
+
|
|
479
|
+
expect(await capabilitiesCommand(makeDeps(io, client), { platform: 'android' })).toBe(
|
|
480
|
+
EXIT.ok,
|
|
481
|
+
)
|
|
482
|
+
expect(requestedPlatform).toBe('android')
|
|
483
|
+
expect(io.outLines[0]).toBe(
|
|
484
|
+
'android capability contract v1 (payload limit 4096 bytes)',
|
|
485
|
+
)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('filters Device Installations by Android in human and JSON output', async () => {
|
|
489
|
+
const devices = [
|
|
490
|
+
{
|
|
491
|
+
device_id: 'dev_ios',
|
|
492
|
+
display_name: 'iPhone',
|
|
493
|
+
platform: 'ios' as const,
|
|
494
|
+
permission_status: 'authorized',
|
|
495
|
+
registration_healthy: true,
|
|
496
|
+
app_version: '1.0.0',
|
|
497
|
+
app_build: '1',
|
|
498
|
+
os_version: '19',
|
|
499
|
+
capabilities: ['answer'] as const,
|
|
500
|
+
support: currentSupport,
|
|
501
|
+
support_state: 'current' as const,
|
|
502
|
+
derived_status: 'working' as const,
|
|
503
|
+
status_message: null,
|
|
504
|
+
last_seen_at: null,
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
device_id: 'dev_android',
|
|
508
|
+
display_name: 'Pixel',
|
|
509
|
+
platform: 'android' as const,
|
|
510
|
+
permission_status: 'authorized',
|
|
511
|
+
registration_healthy: true,
|
|
512
|
+
app_version: '1.0.0',
|
|
513
|
+
app_build: '2',
|
|
514
|
+
os_version: '16',
|
|
515
|
+
capabilities: ['answer'] as const,
|
|
516
|
+
support: currentSupport,
|
|
517
|
+
support_state: 'current' as const,
|
|
518
|
+
derived_status: 'working' as const,
|
|
519
|
+
status_message: null,
|
|
520
|
+
last_seen_at: null,
|
|
521
|
+
},
|
|
522
|
+
]
|
|
523
|
+
const client = { listDevices: async () => ({ devices }) } as unknown as ApiClient
|
|
524
|
+
|
|
525
|
+
const human = new CapturedIo()
|
|
526
|
+
expect(
|
|
527
|
+
await devicesCommand(makeDeps(human, client), { platform: 'android' }),
|
|
528
|
+
).toBe(EXIT.ok)
|
|
529
|
+
expect(human.outLines).toEqual(['dev_android Pixel android Working'])
|
|
530
|
+
|
|
531
|
+
const json = new CapturedIo()
|
|
532
|
+
expect(
|
|
533
|
+
await devicesCommand(makeDeps(json, client), { platform: 'android', json: true }),
|
|
534
|
+
).toBe(EXIT.ok)
|
|
535
|
+
expect(JSON.parse(json.outLines[0]!)['devices']).toEqual([devices[1]])
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('rejects an unknown Device Installation platform filter locally', async () => {
|
|
539
|
+
const io = new CapturedIo()
|
|
540
|
+
let calls = 0
|
|
541
|
+
const client = {
|
|
542
|
+
listDevices: async () => {
|
|
543
|
+
calls += 1
|
|
544
|
+
return { devices: [] }
|
|
545
|
+
},
|
|
546
|
+
} as unknown as ApiClient
|
|
547
|
+
|
|
548
|
+
expect(await devicesCommand(makeDeps(io, client), { platform: 'linux' })).toBe(
|
|
549
|
+
EXIT.usage,
|
|
550
|
+
)
|
|
551
|
+
expect(calls).toBe(0)
|
|
552
|
+
expect(io.errLines.join('\n')).toContain('ios or macos or android')
|
|
553
|
+
})
|
|
554
|
+
|
|
456
555
|
it('rejects an invalid draft before calling submit', async () => {
|
|
457
556
|
const io = new CapturedIo()
|
|
458
557
|
let submitCalls = 0
|
|
@@ -474,6 +573,86 @@ describe('command contracts', () => {
|
|
|
474
573
|
expect(io.errLines.join('\n')).toContain('project')
|
|
475
574
|
})
|
|
476
575
|
|
|
576
|
+
it('authors and validates an Android-specific Notification Request', async () => {
|
|
577
|
+
const io = new CapturedIo()
|
|
578
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
579
|
+
const client = {
|
|
580
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
581
|
+
submitted = body
|
|
582
|
+
return receipt
|
|
583
|
+
},
|
|
584
|
+
} as unknown as ApiClient
|
|
585
|
+
|
|
586
|
+
expect(
|
|
587
|
+
await sendCommand(makeDeps(io, client), {
|
|
588
|
+
kind: 'done',
|
|
589
|
+
title: 'Android build finished',
|
|
590
|
+
body: 'All checks passed.',
|
|
591
|
+
platform: 'android',
|
|
592
|
+
sound: 'none',
|
|
593
|
+
data: ['run_id=42'],
|
|
594
|
+
}),
|
|
595
|
+
).toBe(EXIT.ok)
|
|
596
|
+
expect(submitted?.draft.platform).toEqual({
|
|
597
|
+
android: { sound: null, custom_data: { run_id: '42' } },
|
|
598
|
+
})
|
|
599
|
+
expect(io.errLines).toEqual([])
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('prints Android downgrade warnings before submitting', async () => {
|
|
603
|
+
const io = new CapturedIo()
|
|
604
|
+
let submitCalls = 0
|
|
605
|
+
const client = {
|
|
606
|
+
submit: async () => {
|
|
607
|
+
submitCalls += 1
|
|
608
|
+
return receipt
|
|
609
|
+
},
|
|
610
|
+
} as unknown as ApiClient
|
|
611
|
+
|
|
612
|
+
expect(
|
|
613
|
+
await sendCommand(makeDeps(io, client), {
|
|
614
|
+
kind: 'update',
|
|
615
|
+
title: 'Android evidence',
|
|
616
|
+
body: 'See the attached result.',
|
|
617
|
+
platform: 'android',
|
|
618
|
+
image: ['med_result'],
|
|
619
|
+
threadId: 'android-results',
|
|
620
|
+
}),
|
|
621
|
+
).toBe(EXIT.ok)
|
|
622
|
+
expect(submitCalls).toBe(1)
|
|
623
|
+
expect(io.errLines).toEqual(
|
|
624
|
+
expect.arrayContaining([
|
|
625
|
+
expect.stringContaining('presentation.media'),
|
|
626
|
+
expect.stringContaining('platform.android.thread_id'),
|
|
627
|
+
]),
|
|
628
|
+
)
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
it('rejects an Android interruption level before submission', async () => {
|
|
632
|
+
const io = new CapturedIo()
|
|
633
|
+
let submitCalls = 0
|
|
634
|
+
const client = {
|
|
635
|
+
submit: async () => {
|
|
636
|
+
submitCalls += 1
|
|
637
|
+
return receipt
|
|
638
|
+
},
|
|
639
|
+
} as unknown as ApiClient
|
|
640
|
+
|
|
641
|
+
expect(
|
|
642
|
+
await sendCommand(makeDeps(io, client), {
|
|
643
|
+
kind: 'update',
|
|
644
|
+
title: 'Android update',
|
|
645
|
+
body: 'Ready.',
|
|
646
|
+
platform: 'android',
|
|
647
|
+
level: 'active',
|
|
648
|
+
}),
|
|
649
|
+
).toBe(EXIT.usage)
|
|
650
|
+
expect(submitCalls).toBe(0)
|
|
651
|
+
expect(io.errLines.join('\n')).toContain(
|
|
652
|
+
'Android does not support caller-selected interruption levels',
|
|
653
|
+
)
|
|
654
|
+
})
|
|
655
|
+
|
|
477
656
|
it('infers Project and exact Claude Source Context without printing the opaque id', async () => {
|
|
478
657
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'Notifai Project '))
|
|
479
658
|
const io = new CapturedIo()
|
|
@@ -557,6 +736,48 @@ describe('command contracts', () => {
|
|
|
557
736
|
})
|
|
558
737
|
})
|
|
559
738
|
|
|
739
|
+
it('uses the exact Orca worktree title for an active Codex session send', async () => {
|
|
740
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-codex-title-'))
|
|
741
|
+
const io = new CapturedIo()
|
|
742
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
743
|
+
const client = {
|
|
744
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
745
|
+
submitted = body
|
|
746
|
+
return receipt
|
|
747
|
+
},
|
|
748
|
+
} as unknown as ApiClient
|
|
749
|
+
const worktreeId = `repo-123::${cwd}`
|
|
750
|
+
const deps = {
|
|
751
|
+
...makeDeps(io, client),
|
|
752
|
+
cwd,
|
|
753
|
+
env: {
|
|
754
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
755
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
756
|
+
CODEX_THREAD_ID: 'orca-codex-thread',
|
|
757
|
+
TERM_PROGRAM: 'Orca',
|
|
758
|
+
ORCA_WORKTREE_ID: worktreeId,
|
|
759
|
+
},
|
|
760
|
+
orcaSessionTitle: (lookupEnv: NodeJS.ProcessEnv) => {
|
|
761
|
+
expect(lookupEnv['ORCA_WORKTREE_ID']).toBe(worktreeId)
|
|
762
|
+
return 'Worker - semantic session implementation'
|
|
763
|
+
},
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
expect(
|
|
767
|
+
await sendCommand(deps, {
|
|
768
|
+
title: 'Resolver implemented',
|
|
769
|
+
body: 'The worktree title belongs to every harness Orca starts in it.',
|
|
770
|
+
kind: 'done',
|
|
771
|
+
}),
|
|
772
|
+
).toBe(EXIT.ok)
|
|
773
|
+
|
|
774
|
+
expect(submitted?.draft.source).toMatchObject({
|
|
775
|
+
session_id: 'orca-codex-thread',
|
|
776
|
+
session_label: 'Worker - semantic session implementation',
|
|
777
|
+
harness: 'codex',
|
|
778
|
+
})
|
|
779
|
+
})
|
|
780
|
+
|
|
560
781
|
it('falls back safely when Orca returns a private path as its title', async () => {
|
|
561
782
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-private-title-'))
|
|
562
783
|
const io = new CapturedIo()
|
|
@@ -801,7 +1022,7 @@ describe('command contracts', () => {
|
|
|
801
1022
|
})
|
|
802
1023
|
|
|
803
1024
|
it('keeps only the current public notification-authoring flags', () => {
|
|
804
|
-
const source = readFileSync(new URL('./
|
|
1025
|
+
const source = readFileSync(new URL('./program.ts', import.meta.url), 'utf8')
|
|
805
1026
|
expect(source).not.toContain(".option('--badge")
|
|
806
1027
|
expect(source).not.toContain(".option('--relevance")
|
|
807
1028
|
expect(source).not.toContain(".option('--target-content-id")
|
|
@@ -823,33 +1044,6 @@ describe('command contracts', () => {
|
|
|
823
1044
|
expect(source).not.toContain('Kind profiles apply automatically')
|
|
824
1045
|
})
|
|
825
1046
|
|
|
826
|
-
it('rejects a question nobody will wait for', async () => {
|
|
827
|
-
// --reply asks; --no-block declares nothing will wait. The answer would be
|
|
828
|
-
// captured server-side and then reachable only by hand, so the user taps a
|
|
829
|
-
// real button and nothing happens — worse than never asking, because it
|
|
830
|
-
// spends their attention and their trust in the channel.
|
|
831
|
-
const io = new CapturedIo()
|
|
832
|
-
let submitCalls = 0
|
|
833
|
-
const client = {
|
|
834
|
-
submit: async () => {
|
|
835
|
-
submitCalls += 1
|
|
836
|
-
return receipt
|
|
837
|
-
},
|
|
838
|
-
} as unknown as ApiClient
|
|
839
|
-
|
|
840
|
-
expect(
|
|
841
|
-
await sendCommand(makeDeps(io, client), {
|
|
842
|
-
title: 'Question',
|
|
843
|
-
body: 'Deploy?',
|
|
844
|
-
reply: true,
|
|
845
|
-
replyWindow: 3_600,
|
|
846
|
-
noBlock: true,
|
|
847
|
-
}),
|
|
848
|
-
).toBe(EXIT.usage)
|
|
849
|
-
expect(submitCalls).toBe(0)
|
|
850
|
-
expect(io.errLines.join('\n')).toContain('notifai ask')
|
|
851
|
-
})
|
|
852
|
-
|
|
853
1047
|
it('maps reply flags into the draft and waits for the answer', async () => {
|
|
854
1048
|
const io = new CapturedIo()
|
|
855
1049
|
let submitted: SubmitNotificationRequestT | undefined
|
|
@@ -907,7 +1101,7 @@ describe('command contracts', () => {
|
|
|
907
1101
|
|
|
908
1102
|
expect(await sendCommand(makeDeps(io, client), flags)).toBe(EXIT.ok)
|
|
909
1103
|
expect(io.errLines).toEqual([
|
|
910
|
-
'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --
|
|
1104
|
+
'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --choice) so it can be answered from the notification.',
|
|
911
1105
|
])
|
|
912
1106
|
})
|
|
913
1107
|
|
|
@@ -929,7 +1123,7 @@ describe('command contracts', () => {
|
|
|
929
1123
|
expect(io.errLines).toEqual([])
|
|
930
1124
|
})
|
|
931
1125
|
|
|
932
|
-
it('rejects --
|
|
1126
|
+
it('rejects --choice without the --reply action it configures', async () => {
|
|
933
1127
|
const io = new CapturedIo()
|
|
934
1128
|
let submitCalls = 0
|
|
935
1129
|
const client = {
|
|
@@ -943,12 +1137,12 @@ describe('command contracts', () => {
|
|
|
943
1137
|
await sendCommand(makeDeps(io, client), { kind: 'update',
|
|
944
1138
|
title: 'Deploy?',
|
|
945
1139
|
body: 'Choose when ready.',
|
|
946
|
-
|
|
1140
|
+
choice: ['Now', 'Later'],
|
|
947
1141
|
}),
|
|
948
1142
|
).toBe(EXIT.usage)
|
|
949
1143
|
expect(submitCalls).toBe(0)
|
|
950
1144
|
expect(io.errLines).toEqual([
|
|
951
|
-
'Use --reply with --reply-timeout, --reply-window,
|
|
1145
|
+
'Use --reply with --reply-timeout, --reply-window, or --choice.',
|
|
952
1146
|
])
|
|
953
1147
|
})
|
|
954
1148
|
|
|
@@ -1148,13 +1342,14 @@ describe('command contracts', () => {
|
|
|
1148
1342
|
})
|
|
1149
1343
|
})
|
|
1150
1344
|
|
|
1151
|
-
it('prints
|
|
1345
|
+
it('prints one combined reply_result object on exit 3, nothing before the wait', async () => {
|
|
1152
1346
|
const io = new CapturedIo()
|
|
1153
1347
|
let now = 0
|
|
1154
1348
|
const client = {
|
|
1155
1349
|
submit: async () => receipt,
|
|
1156
1350
|
replies: async (_requestId: string, options: { waitSeconds: number }) => {
|
|
1157
|
-
|
|
1351
|
+
// Nothing on stdout mid-wait: the single object arrives at the end.
|
|
1352
|
+
expect(io.outLines).toHaveLength(0)
|
|
1158
1353
|
now += options.waitSeconds * 1_000
|
|
1159
1354
|
return replyResponse()
|
|
1160
1355
|
},
|
|
@@ -1170,11 +1365,12 @@ describe('command contracts', () => {
|
|
|
1170
1365
|
json: true,
|
|
1171
1366
|
}),
|
|
1172
1367
|
).toBe(EXIT.noReply)
|
|
1173
|
-
expect(io.outLines).toHaveLength(
|
|
1368
|
+
expect(io.outLines).toHaveLength(1)
|
|
1174
1369
|
// `degraded` is part of the shape on every reply wait, not only when it is
|
|
1175
1370
|
// true: an agent must be able to read it without knowing it might be absent.
|
|
1176
|
-
expect(JSON.parse(io.outLines[
|
|
1371
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1177
1372
|
type: 'reply_result',
|
|
1373
|
+
receipt,
|
|
1178
1374
|
request_id: receipt.request_id,
|
|
1179
1375
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
1180
1376
|
replies: [],
|
|
@@ -1189,7 +1385,7 @@ describe('command contracts', () => {
|
|
|
1189
1385
|
expect(io.errLines.join('\n')).toContain(`notifai close ${receipt.request_id}`)
|
|
1190
1386
|
})
|
|
1191
1387
|
|
|
1192
|
-
it('prints
|
|
1388
|
+
it('prints one combined reply_result object with the receipt when answered', async () => {
|
|
1193
1389
|
const io = new CapturedIo()
|
|
1194
1390
|
const client = {
|
|
1195
1391
|
submit: async () => receipt,
|
|
@@ -1205,10 +1401,10 @@ describe('command contracts', () => {
|
|
|
1205
1401
|
json: true,
|
|
1206
1402
|
}),
|
|
1207
1403
|
).toBe(EXIT.ok)
|
|
1208
|
-
expect(io.outLines).toHaveLength(
|
|
1209
|
-
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1210
|
-
expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
|
|
1404
|
+
expect(io.outLines).toHaveLength(1)
|
|
1405
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1211
1406
|
type: 'reply_result',
|
|
1407
|
+
receipt,
|
|
1212
1408
|
request_id: receipt.request_id,
|
|
1213
1409
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
1214
1410
|
replies: [reply],
|
|
@@ -1220,6 +1416,41 @@ describe('command contracts', () => {
|
|
|
1220
1416
|
})
|
|
1221
1417
|
})
|
|
1222
1418
|
|
|
1419
|
+
it('still prints the one reply_result object when the wait itself faults', async () => {
|
|
1420
|
+
const io = new CapturedIo()
|
|
1421
|
+
const client = {
|
|
1422
|
+
submit: async () => receipt,
|
|
1423
|
+
replies: async () => {
|
|
1424
|
+
throw new ApiCallError(404, 'not_found', 'no such request')
|
|
1425
|
+
},
|
|
1426
|
+
} as unknown as ApiClient
|
|
1427
|
+
|
|
1428
|
+
expect(
|
|
1429
|
+
await sendCommand(makeDeps(io, client), {
|
|
1430
|
+
title: 'Question',
|
|
1431
|
+
body: 'Deploy?',
|
|
1432
|
+
reply: true,
|
|
1433
|
+
replyTimeout: 10,
|
|
1434
|
+
json: true,
|
|
1435
|
+
}),
|
|
1436
|
+
).toBe(EXIT.failed)
|
|
1437
|
+
expect(io.outLines).toHaveLength(1)
|
|
1438
|
+
// The send is durable even though the wait failed; `degraded: true` says
|
|
1439
|
+
// "could not find out", not "no answer".
|
|
1440
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1441
|
+
type: 'reply_result',
|
|
1442
|
+
receipt,
|
|
1443
|
+
request_id: receipt.request_id,
|
|
1444
|
+
replies: [],
|
|
1445
|
+
agent_acknowledgement_required: true,
|
|
1446
|
+
agent_acknowledgement_text_required: true,
|
|
1447
|
+
agent_acknowledgement: null,
|
|
1448
|
+
acknowledgement_command: null,
|
|
1449
|
+
degraded: true,
|
|
1450
|
+
})
|
|
1451
|
+
expect(io.errLines.join('\n')).toContain('reply wait failed')
|
|
1452
|
+
})
|
|
1453
|
+
|
|
1223
1454
|
it('acknowledges non-interactively, trims text, emits JSON, and logs request identity', async () => {
|
|
1224
1455
|
const io = new CapturedIo()
|
|
1225
1456
|
let submitted: { requestId: string; text: string } | undefined
|
|
@@ -3175,11 +3406,18 @@ describe('init', () => {
|
|
|
3175
3406
|
display_name: 'Mac',
|
|
3176
3407
|
platform: 'macos' as const,
|
|
3177
3408
|
}
|
|
3409
|
+
const readyAndroid = {
|
|
3410
|
+
...readyIphone,
|
|
3411
|
+
device_id: 'dev_android',
|
|
3412
|
+
display_name: 'Pixel',
|
|
3413
|
+
platform: 'android' as const,
|
|
3414
|
+
os_version: '16',
|
|
3415
|
+
}
|
|
3178
3416
|
|
|
3179
3417
|
function setupEvidence(
|
|
3180
3418
|
requestId: string,
|
|
3181
3419
|
companionReceipt: EvidenceSnapshot['deliveries'][number]['companion_receipt'],
|
|
3182
|
-
device = readyIphone,
|
|
3420
|
+
device: typeof readyIphone | typeof readyAndroid = readyIphone,
|
|
3183
3421
|
): EvidenceSnapshot {
|
|
3184
3422
|
return {
|
|
3185
3423
|
request_id: requestId,
|
|
@@ -3215,15 +3453,18 @@ describe('init', () => {
|
|
|
3215
3453
|
}
|
|
3216
3454
|
}
|
|
3217
3455
|
|
|
3218
|
-
function setupReceipt(
|
|
3456
|
+
function setupReceipt(
|
|
3457
|
+
requestId = 'req_setup',
|
|
3458
|
+
device: typeof readyIphone | typeof readyAndroid = readyIphone,
|
|
3459
|
+
): SubmissionReceipt {
|
|
3219
3460
|
return {
|
|
3220
3461
|
...receipt,
|
|
3221
3462
|
request_id: requestId,
|
|
3222
3463
|
deliveries: [
|
|
3223
3464
|
{
|
|
3224
3465
|
...receipt.deliveries[0]!,
|
|
3225
|
-
device_id:
|
|
3226
|
-
device_name:
|
|
3466
|
+
device_id: device.device_id,
|
|
3467
|
+
device_name: device.display_name,
|
|
3227
3468
|
},
|
|
3228
3469
|
],
|
|
3229
3470
|
}
|
|
@@ -3735,7 +3976,9 @@ describe('init', () => {
|
|
|
3735
3976
|
expect(out).toContain('Next: Your devices')
|
|
3736
3977
|
expect(out).toContain('https://test.notifai.invalid/support')
|
|
3737
3978
|
expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
|
|
3738
|
-
expect(out).toContain(
|
|
3979
|
+
expect(out).toContain(
|
|
3980
|
+
'no active Companion device registered yet; install Notifai via https://test.notifai.invalid/support',
|
|
3981
|
+
)
|
|
3739
3982
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
3740
3983
|
})
|
|
3741
3984
|
|
|
@@ -3784,7 +4027,7 @@ describe('init', () => {
|
|
|
3784
4027
|
})
|
|
3785
4028
|
|
|
3786
4029
|
it.each([
|
|
3787
|
-
['denied', '
|
|
4030
|
+
['denied', "the device's Settings"],
|
|
3788
4031
|
['not_determined', 'allow its notification prompt'],
|
|
3789
4032
|
])('gives one permission-specific next action for %s', async (permission, expected) => {
|
|
3790
4033
|
const cwd = mkdtempSync(path.join(os.tmpdir(), `init-permission-${permission}-`))
|
|
@@ -3804,12 +4047,12 @@ describe('init', () => {
|
|
|
3804
4047
|
|
|
3805
4048
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
3806
4049
|
const out = io.outLines.join('\n')
|
|
3807
|
-
expect(out).toContain(`iPhone (${permission})`)
|
|
4050
|
+
expect(out).toContain(`iPhone (ios, ${permission})`)
|
|
3808
4051
|
expect(out).toContain(expected)
|
|
3809
4052
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
3810
4053
|
})
|
|
3811
4054
|
|
|
3812
|
-
it('ignores a dormant Mac installation while waiting for an
|
|
4055
|
+
it('ignores a dormant Mac installation while waiting for an active Companion, then proves the iPhone receipt', async () => {
|
|
3813
4056
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-bridge-'))
|
|
3814
4057
|
const io = new InteractiveIo()
|
|
3815
4058
|
let now = 0
|
|
@@ -3856,7 +4099,9 @@ describe('init', () => {
|
|
|
3856
4099
|
])
|
|
3857
4100
|
expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
|
|
3858
4101
|
expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
|
|
3859
|
-
expect(io.spinnerEvents).toContain(
|
|
4102
|
+
expect(io.spinnerEvents).toContain(
|
|
4103
|
+
'message:Waiting for a Companion App to sign in and register…',
|
|
4104
|
+
)
|
|
3860
4105
|
expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
|
|
3861
4106
|
expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
|
|
3862
4107
|
expect(io.outLines.join('\n')).toContain(
|
|
@@ -4047,6 +4292,51 @@ describe('init', () => {
|
|
|
4047
4292
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
4048
4293
|
})
|
|
4049
4294
|
|
|
4295
|
+
it('treats Android as active Companion readiness and sends a platform-correct proof', async () => {
|
|
4296
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-android-proof-'))
|
|
4297
|
+
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
4298
|
+
writeFileSync(
|
|
4299
|
+
path.join(cwd, '.notifai', 'config.toml'),
|
|
4300
|
+
'project = "android-project"\ninterruption_level = "passive"\n',
|
|
4301
|
+
)
|
|
4302
|
+
const io = new CapturedIo()
|
|
4303
|
+
let submitted: SubmitNotificationRequestT | null = null
|
|
4304
|
+
const client = {
|
|
4305
|
+
health: async () => true,
|
|
4306
|
+
listDevices: async () => ({ devices: [readyAndroid] }),
|
|
4307
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
4308
|
+
submitted = body
|
|
4309
|
+
return setupReceipt('req_android_setup', readyAndroid)
|
|
4310
|
+
},
|
|
4311
|
+
evidence: async (requestId: string) =>
|
|
4312
|
+
setupEvidence(
|
|
4313
|
+
requestId,
|
|
4314
|
+
{
|
|
4315
|
+
state: 'observed',
|
|
4316
|
+
observed_at: '2026-08-05T18:00:02.000Z',
|
|
4317
|
+
latency_ms: 1_000,
|
|
4318
|
+
},
|
|
4319
|
+
readyAndroid,
|
|
4320
|
+
),
|
|
4321
|
+
} as unknown as ApiClient
|
|
4322
|
+
const deps = {
|
|
4323
|
+
...makeDeps(io, client),
|
|
4324
|
+
cwd,
|
|
4325
|
+
env: isolatedEnv(cwd),
|
|
4326
|
+
}
|
|
4327
|
+
|
|
4328
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
4329
|
+
expect(submitted?.draft.targets).toEqual({
|
|
4330
|
+
mode: 'selected',
|
|
4331
|
+
device_ids: [readyAndroid.device_id],
|
|
4332
|
+
})
|
|
4333
|
+
expect(submitted?.draft.platform).toEqual({ android: { sound: null } })
|
|
4334
|
+
expect(io.outLines.join('\n')).toContain(
|
|
4335
|
+
"Companion Receipt (the app's delivery confirmation) observed from Pixel.",
|
|
4336
|
+
)
|
|
4337
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
4338
|
+
})
|
|
4339
|
+
|
|
4050
4340
|
it('does not treat a macOS-only installation as active-release readiness', async () => {
|
|
4051
4341
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
|
|
4052
4342
|
const io = new CapturedIo()
|
|
@@ -4072,11 +4362,11 @@ describe('init', () => {
|
|
|
4072
4362
|
expect(submitCalls).toBe(0)
|
|
4073
4363
|
const out = io.outLines.join('\n')
|
|
4074
4364
|
expect(out).toContain('Next: Your devices')
|
|
4075
|
-
expect(out).toContain('no
|
|
4365
|
+
expect(out).toContain('no active Companion device registered yet')
|
|
4076
4366
|
expect(out).not.toContain('All set.')
|
|
4077
4367
|
expect(out).not.toMatch(/Companion Receipt observed/i)
|
|
4078
4368
|
|
|
4079
|
-
// A human doctor must render the same
|
|
4369
|
+
// A human doctor must render the same active-Companion readiness failure.
|
|
4080
4370
|
const doctorIo = new PlainInteractiveIo()
|
|
4081
4371
|
expect(
|
|
4082
4372
|
await doctorCommand(
|
|
@@ -4090,8 +4380,10 @@ describe('init', () => {
|
|
|
4090
4380
|
).toBe(EXIT.failed)
|
|
4091
4381
|
const doctorOut = doctorIo.outLines.join('\n')
|
|
4092
4382
|
expect(doctorOut).toMatch(/FAIL\s+Your devices:/)
|
|
4093
|
-
expect(doctorOut).toContain('no
|
|
4094
|
-
expect(doctorOut).toContain(
|
|
4383
|
+
expect(doctorOut).toContain('no active Companion device registered yet')
|
|
4384
|
+
expect(doctorOut).toContain(
|
|
4385
|
+
'Delivery proof: not checked — no iPhone or Android Companion App is ready',
|
|
4386
|
+
)
|
|
4095
4387
|
})
|
|
4096
4388
|
|
|
4097
4389
|
it('stops login when the approval page reports no Alpha access', async () => {
|
|
@@ -4387,7 +4679,8 @@ describe('an outage is not an answer', () => {
|
|
|
4387
4679
|
json: true,
|
|
4388
4680
|
})
|
|
4389
4681
|
|
|
4390
|
-
|
|
4682
|
+
expect(io.outLines).toHaveLength(1)
|
|
4683
|
+
const payload = JSON.parse(io.outLines[0] ?? '{}') as { degraded: boolean }
|
|
4391
4684
|
expect(payload.degraded).toBe(true)
|
|
4392
4685
|
})
|
|
4393
4686
|
|
|
@@ -4568,7 +4861,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4568
4861
|
const pending = readSessionState('codex-media-thread', env).pending?.[0]
|
|
4569
4862
|
expect(uploaded).toEqual(['med_ask_1', 'med_ask_2'])
|
|
4570
4863
|
expect(pending?.body).toBe(
|
|
4571
|
-
'
|
|
4864
|
+
'Compare  with .',
|
|
4572
4865
|
)
|
|
4573
4866
|
expect(pending?.media).toEqual([
|
|
4574
4867
|
{ media_id: 'med_ask_1', alt: 'First option' },
|
|
@@ -5591,7 +5884,7 @@ describe('command failures carrying server details', () => {
|
|
|
5591
5884
|
})
|
|
5592
5885
|
|
|
5593
5886
|
describe('question sets', () => {
|
|
5594
|
-
it('maps --
|
|
5887
|
+
it('maps --multi into the single question', async () => {
|
|
5595
5888
|
const io = new CapturedIo()
|
|
5596
5889
|
let submitted: SubmitNotificationRequestT | undefined
|
|
5597
5890
|
const client = {
|
|
@@ -5608,8 +5901,8 @@ describe('question sets', () => {
|
|
|
5608
5901
|
body: 'Which fronts?',
|
|
5609
5902
|
reply: true,
|
|
5610
5903
|
replyTimeout: 30,
|
|
5611
|
-
|
|
5612
|
-
|
|
5904
|
+
choice: ['CLI', 'Server', 'Apps'],
|
|
5905
|
+
multi: true,
|
|
5613
5906
|
}),
|
|
5614
5907
|
).toBe(EXIT.ok)
|
|
5615
5908
|
expect(submitted?.draft.reply?.questions?.[0]).toMatchObject({
|
|
@@ -5643,7 +5936,10 @@ describe('question sets', () => {
|
|
|
5643
5936
|
expect(submitted?.draft.reply?.questions[0]?.text).toBe('Which environment?')
|
|
5644
5937
|
})
|
|
5645
5938
|
|
|
5646
|
-
it('
|
|
5939
|
+
it('keeps the question out of a body that carries context', () => {
|
|
5940
|
+
// The question already travels as the title and as structured questions;
|
|
5941
|
+
// repeating it in the body showed it twice on the lock screen and the
|
|
5942
|
+
// reply screen.
|
|
5647
5943
|
expect(
|
|
5648
5944
|
buildQuestions(
|
|
5649
5945
|
{ body: '## Why\nThe release window closes today.' },
|
|
@@ -5651,12 +5947,28 @@ describe('question sets', () => {
|
|
|
5651
5947
|
),
|
|
5652
5948
|
).toMatchObject({
|
|
5653
5949
|
ok: true,
|
|
5654
|
-
body: '
|
|
5950
|
+
body: '## Why\nThe release window closes today.',
|
|
5655
5951
|
questions: [{ text: 'Which environment?' }],
|
|
5656
5952
|
})
|
|
5657
5953
|
})
|
|
5658
5954
|
|
|
5659
|
-
it('
|
|
5955
|
+
it('lets the question stand in for the body only when there is no context', () => {
|
|
5956
|
+
expect(buildQuestions({}, 'Which environment?')).toMatchObject({
|
|
5957
|
+
ok: true,
|
|
5958
|
+
body: 'Which environment?',
|
|
5959
|
+
})
|
|
5960
|
+
expect(
|
|
5961
|
+
buildQuestions(
|
|
5962
|
+
{ form: JSON.stringify({ questions: [{ text: 'Deploy where?' }, { text: 'What should I monitor?' }] }) },
|
|
5963
|
+
undefined,
|
|
5964
|
+
),
|
|
5965
|
+
).toMatchObject({
|
|
5966
|
+
ok: true,
|
|
5967
|
+
body: '1. Deploy where?\n2. What should I monitor?',
|
|
5968
|
+
})
|
|
5969
|
+
})
|
|
5970
|
+
|
|
5971
|
+
it('keeps form questions out of a body that carries form context', () => {
|
|
5660
5972
|
expect(
|
|
5661
5973
|
buildQuestions(
|
|
5662
5974
|
{
|
|
@@ -5669,8 +5981,7 @@ describe('question sets', () => {
|
|
|
5669
5981
|
),
|
|
5670
5982
|
).toMatchObject({
|
|
5671
5983
|
ok: true,
|
|
5672
|
-
body:
|
|
5673
|
-
'1. Deploy where?\n2. What should I monitor?\n\n## Context\nTraffic is elevated.',
|
|
5984
|
+
body: '## Context\nTraffic is elevated.',
|
|
5674
5985
|
})
|
|
5675
5986
|
})
|
|
5676
5987
|
|
package/src/commands.ts
CHANGED
|
@@ -47,6 +47,11 @@ export {
|
|
|
47
47
|
configShowCommand,
|
|
48
48
|
configUnsetCommand,
|
|
49
49
|
} from './commands-config.js'
|
|
50
|
+
export {
|
|
51
|
+
guidanceSetCommand,
|
|
52
|
+
guidanceShowCommand,
|
|
53
|
+
guidanceUnsetCommand,
|
|
54
|
+
} from './commands-guidance.js'
|
|
50
55
|
export { logsCommand, parseSince, type LogsFlags } from './commands-logs.js'
|
|
51
56
|
export { SKILLS_SOURCE } from './commands-skill.js'
|
|
52
57
|
export {
|