@raidiant/notifai 7.0.2 → 9.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 +75 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- 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.d.ts +1 -0
- package/dist/commands-doctor.js +85 -27
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +27 -0
- package/dist/commands-guidance.js +163 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +16 -5
- package/dist/commands-hooks.js +433 -38
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +139 -54
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +66 -8
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +105 -39
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +30 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -5
- package/dist/config.js +37 -8
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +50 -0
- package/dist/guidance-content.js +240 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +69 -0
- package/dist/guidance.js +173 -0
- package/dist/guidance.js.map +1 -0
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +238 -92
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +14 -9
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +5 -487
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.d.ts +49 -0
- package/dist/program.js +576 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +12 -10
- package/dist/send.js +65 -29
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +6 -4
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-devices.ts +28 -9
- package/src/commands-doctor.ts +116 -25
- package/src/commands-guidance.ts +216 -0
- package/src/commands-hooks.ts +513 -42
- package/src/commands-init.ts +170 -60
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +72 -8
- package/src/commands-send.ts +123 -38
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1490 -197
- package/src/commands.ts +6 -0
- package/src/config-schema.ts +32 -16
- package/src/config.test.ts +57 -2
- package/src/config.ts +49 -10
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +256 -0
- package/src/guidance.test.ts +299 -0
- package/src/guidance.ts +233 -0
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +783 -170
- package/src/hooks.ts +306 -131
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +14 -9
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +6 -614
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +237 -0
- package/src/program.ts +751 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +81 -34
- package/src/session-activation.ts +45 -0
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +153 -12
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/src/hooks.test.ts
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
MAX_CONTINUATION_COUNT,
|
|
51
51
|
MAX_HELD_DELIVERIES,
|
|
52
52
|
orphanRetirements,
|
|
53
|
+
pendingAnsweredByPrompt,
|
|
53
54
|
pruneAbandonedSessions,
|
|
54
55
|
releaseQuestionPush,
|
|
55
56
|
readMatchingProjectSessionPointer,
|
|
@@ -127,6 +128,8 @@ interface Recorder {
|
|
|
127
128
|
closed: string[]
|
|
128
129
|
/** Simulates an offline machine; retirement has to survive one. */
|
|
129
130
|
failSubmits?: boolean
|
|
131
|
+
/** Simulates an unreachable close fence so retirement stays queued. */
|
|
132
|
+
failCloses?: boolean
|
|
130
133
|
/** Runs while an agent_question submit is in flight, before onSubmitted. */
|
|
131
134
|
beforeQuestionSubmit?: () => void
|
|
132
135
|
/** Lets race tests hold every replies call until all contenders are polling. */
|
|
@@ -142,6 +145,16 @@ interface Recorder {
|
|
|
142
145
|
acknowledgementChecks?: string[]
|
|
143
146
|
}
|
|
144
147
|
|
|
148
|
+
function isQuestionSubmit(entry: { draft: { reply?: unknown } }): boolean {
|
|
149
|
+
return entry.draft.reply !== undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isRetirementSubmit(entry: {
|
|
153
|
+
draft: { lifecycle?: { retires_request_id?: string } }
|
|
154
|
+
}): boolean {
|
|
155
|
+
return entry.draft.lifecycle?.retires_request_id !== undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
145
158
|
function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
146
159
|
let submissions = 0
|
|
147
160
|
const recordedReplies = (requestId: string): ReplyView[] => {
|
|
@@ -157,7 +170,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
157
170
|
return {
|
|
158
171
|
beginPairing: notUsed,
|
|
159
172
|
pollPairing: notUsed,
|
|
160
|
-
//
|
|
173
|
+
// Current reply-capable Companion fixtures.
|
|
161
174
|
listDevices: async () => ({
|
|
162
175
|
devices: [
|
|
163
176
|
{
|
|
@@ -215,7 +228,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
215
228
|
health: async () => true,
|
|
216
229
|
submit: async (body) => {
|
|
217
230
|
if (recorder.failSubmits === true) throw new Error('offline')
|
|
218
|
-
if (body.draft.
|
|
231
|
+
if (body.draft.reply !== undefined) recorder.beforeQuestionSubmit?.()
|
|
219
232
|
recorder.submitted.push(body)
|
|
220
233
|
submissions += 1
|
|
221
234
|
const requestId = body.request_id ?? `req_hook_${submissions}`
|
|
@@ -250,6 +263,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
250
263
|
}) satisfies ListRepliesResponse
|
|
251
264
|
},
|
|
252
265
|
closeReplies: async (requestId) => {
|
|
266
|
+
if (recorder.failCloses === true) throw new Error('offline')
|
|
253
267
|
recorder.closed.push(requestId)
|
|
254
268
|
return {
|
|
255
269
|
request_id: requestId,
|
|
@@ -555,6 +569,38 @@ describe('pushing a registered question', () => {
|
|
|
555
569
|
expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBe('attention')
|
|
556
570
|
})
|
|
557
571
|
|
|
572
|
+
it('routes a registered question to an answer-capable Android installation', async () => {
|
|
573
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
574
|
+
const client = {
|
|
575
|
+
...fakeClient(h.recorder, [reply({ text: 'Yes' })]),
|
|
576
|
+
listDevices: async () => ({
|
|
577
|
+
devices: [
|
|
578
|
+
{
|
|
579
|
+
device_id: 'dev_android',
|
|
580
|
+
display_name: 'Pixel',
|
|
581
|
+
platform: 'android' as const,
|
|
582
|
+
permission_status: 'authorized',
|
|
583
|
+
registration_healthy: true,
|
|
584
|
+
capabilities: ['answer'],
|
|
585
|
+
derived_status: 'working',
|
|
586
|
+
last_seen_at: null,
|
|
587
|
+
},
|
|
588
|
+
],
|
|
589
|
+
}),
|
|
590
|
+
} as ApiClient
|
|
591
|
+
const deps = { ...h.deps, clientFactory: () => client }
|
|
592
|
+
writeSessionState('android-answer', h.env, { last_prompt_at: AWAY })
|
|
593
|
+
registerQuestion('android-answer', h.env, { question: 'Ship it?' }, NOW)
|
|
594
|
+
|
|
595
|
+
await hookRunCommand(deps, 'stop', stdin({ session_id: 'android-answer' }))
|
|
596
|
+
|
|
597
|
+
expect(h.recorder.submitted[0]?.draft.targets).toEqual({
|
|
598
|
+
mode: 'selected',
|
|
599
|
+
device_ids: ['dev_android'],
|
|
600
|
+
})
|
|
601
|
+
expect(h.recorder.submitted[0]?.draft.platform?.android?.sound).toBe('attention')
|
|
602
|
+
})
|
|
603
|
+
|
|
558
604
|
it('keeps sparse alt text paired with its original media item', async () => {
|
|
559
605
|
const h = harness([reply({ text: 'Yes' })])
|
|
560
606
|
writeSessionState('media-order', h.env, { last_prompt_at: AWAY })
|
|
@@ -704,7 +750,7 @@ describe('what the hook leaves behind', () => {
|
|
|
704
750
|
)
|
|
705
751
|
|
|
706
752
|
const raw = readFileSync(activeLogPath(h.env), 'utf8')
|
|
707
|
-
expect(raw.
|
|
753
|
+
expect(raw).not.toContain('Allow exactly once')
|
|
708
754
|
const answerRecords = readLogRecords(h.env, { event: ['hook.answer'] }).records
|
|
709
755
|
expect(answerRecords.map((record) => record.data?.['stage'])).toEqual([
|
|
710
756
|
'queued',
|
|
@@ -835,7 +881,7 @@ describe('terminal-first grace window', () => {
|
|
|
835
881
|
|
|
836
882
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present1' }))
|
|
837
883
|
|
|
838
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
884
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
|
|
839
885
|
expect(submittedAt).toBe(NOW)
|
|
840
886
|
})
|
|
841
887
|
|
|
@@ -868,7 +914,7 @@ describe('terminal-first grace window', () => {
|
|
|
868
914
|
|
|
869
915
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
|
|
870
916
|
|
|
871
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
917
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
|
|
872
918
|
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
|
|
873
919
|
})
|
|
874
920
|
|
|
@@ -897,7 +943,7 @@ describe('terminal-first grace window', () => {
|
|
|
897
943
|
expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 360_000)
|
|
898
944
|
expect(h.deps.now?.()).toBeLessThan(NOW + 361_000)
|
|
899
945
|
expect(
|
|
900
|
-
h.recorder.submitted.find((entry) => entry
|
|
946
|
+
h.recorder.submitted.find((entry) => isQuestionSubmit(entry))?.draft.reply
|
|
901
947
|
?.expires_in_seconds,
|
|
902
948
|
).toBeGreaterThanOrEqual(60)
|
|
903
949
|
})
|
|
@@ -1006,7 +1052,7 @@ describe('nagging guards', () => {
|
|
|
1006
1052
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
|
|
1007
1053
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
|
|
1008
1054
|
|
|
1009
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
1055
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
|
|
1010
1056
|
})
|
|
1011
1057
|
|
|
1012
1058
|
it('respects the harness recursion guard', async () => {
|
|
@@ -1034,7 +1080,7 @@ describe('nagging guards', () => {
|
|
|
1034
1080
|
|
|
1035
1081
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3', stop_hook_active: true }))
|
|
1036
1082
|
|
|
1037
|
-
const questions = h.recorder.submitted.filter((entry) => entry
|
|
1083
|
+
const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
|
|
1038
1084
|
expect(questions.map((entry) => entry.draft.presentation.body)).toEqual([
|
|
1039
1085
|
'First question?',
|
|
1040
1086
|
'Follow-up question?',
|
|
@@ -1091,7 +1137,7 @@ describe('late answer collection', () => {
|
|
|
1091
1137
|
|
|
1092
1138
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
|
|
1093
1139
|
|
|
1094
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
1140
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
|
|
1095
1141
|
expect(h.recorder.closed).toContain('req_live')
|
|
1096
1142
|
expect(readSessionState('late-stop', h.env).pending).toBeUndefined()
|
|
1097
1143
|
expect(h.io.outLines).toHaveLength(1)
|
|
@@ -1138,14 +1184,10 @@ describe('late answer collection', () => {
|
|
|
1138
1184
|
stdin({ session_id: 'late-prompt', stop_hook_active: true }),
|
|
1139
1185
|
)
|
|
1140
1186
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
state: 'answered',
|
|
1146
|
-
retires_request_id: 'req_live',
|
|
1147
|
-
})
|
|
1148
|
-
expect(retirement?.lifecycle?.state).not.toBe('answered_elsewhere')
|
|
1187
|
+
expect(h.recorder.closed).toContain('req_live')
|
|
1188
|
+
expect(
|
|
1189
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
1190
|
+
).toHaveLength(0)
|
|
1149
1191
|
expect(readSessionState('late-prompt', h.env).pending).toBeUndefined()
|
|
1150
1192
|
expect(readSessionState('late-prompt', h.env).accepted).toBeUndefined()
|
|
1151
1193
|
})
|
|
@@ -1159,10 +1201,10 @@ describe('OpenCode answer continuation', () => {
|
|
|
1159
1201
|
|
|
1160
1202
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'open1' }), 'opencode')
|
|
1161
1203
|
|
|
1162
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
1204
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
|
|
1163
1205
|
expect(h.recorder.closed).toEqual([])
|
|
1164
1206
|
expect(
|
|
1165
|
-
h.recorder.submitted.filter((entry) => entry
|
|
1207
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
1166
1208
|
).toHaveLength(0)
|
|
1167
1209
|
expect(readSessionState('open1', h.env).pending).toBeUndefined()
|
|
1168
1210
|
expect(h.io.outLines).toEqual([])
|
|
@@ -1236,7 +1278,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1236
1278
|
return {
|
|
1237
1279
|
...client,
|
|
1238
1280
|
submit: async (body: SubmitNotificationRequestT) => {
|
|
1239
|
-
if (body.draft.
|
|
1281
|
+
if (body.draft.reply === undefined) return client.submit(body, 0)
|
|
1240
1282
|
acceptedRequestId = body.request_id
|
|
1241
1283
|
h.recorder.submitted.push(body)
|
|
1242
1284
|
h.recorder.receipts.push(body.request_id!)
|
|
@@ -1464,7 +1506,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1464
1506
|
|
|
1465
1507
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline' }))
|
|
1466
1508
|
|
|
1467
|
-
expect(h.deps.now?.()).toBe(NOW +
|
|
1509
|
+
expect(h.deps.now?.()).toBe(NOW + 57_000)
|
|
1468
1510
|
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1469
1511
|
expect(readSessionState('deadline', h.env).pending).toBeUndefined()
|
|
1470
1512
|
expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
|
|
@@ -1480,7 +1522,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1480
1522
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latency' }))
|
|
1481
1523
|
|
|
1482
1524
|
const windows = h.recorder.submitted
|
|
1483
|
-
.filter((entry) => entry
|
|
1525
|
+
.filter((entry) => isQuestionSubmit(entry))
|
|
1484
1526
|
.map((entry) => entry.draft.reply?.expires_in_seconds)
|
|
1485
1527
|
// How long the user may answer does not shrink because this owner spent
|
|
1486
1528
|
// part of its own budget getting the question out. Both questions stay
|
|
@@ -1520,7 +1562,116 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1520
1562
|
|
|
1521
1563
|
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Second answer')
|
|
1522
1564
|
expect(readSessionState('successor', h.env).pending).toBeUndefined()
|
|
1523
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
1565
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(2)
|
|
1566
|
+
})
|
|
1567
|
+
|
|
1568
|
+
it('does not start another waiter for a live question whose owner lease is already spent', async () => {
|
|
1569
|
+
const h = harness([])
|
|
1570
|
+
h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
|
|
1571
|
+
writeSessionState('spent-owner', h.env, {
|
|
1572
|
+
last_prompt_at: AWAY,
|
|
1573
|
+
pending: [
|
|
1574
|
+
{
|
|
1575
|
+
question: 'Old question?',
|
|
1576
|
+
asked_at: NOW - 3_300_000,
|
|
1577
|
+
request_id: 'req_old',
|
|
1578
|
+
collapse_key: 'question-old',
|
|
1579
|
+
device_ids: ['dev_iphone'],
|
|
1580
|
+
reply_deadline_at: NOW + 86_400_000,
|
|
1581
|
+
owner_deadline_at: NOW - 1,
|
|
1582
|
+
},
|
|
1583
|
+
],
|
|
1584
|
+
})
|
|
1585
|
+
|
|
1586
|
+
const started = h.deps.now?.() ?? NOW
|
|
1587
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'spent-owner' }))
|
|
1588
|
+
|
|
1589
|
+
expect(h.deps.now?.()).toBeLessThan(started + 30_000)
|
|
1590
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
|
|
1591
|
+
expect(readSessionState('spent-owner', h.env).pending?.[0]?.request_id).toBe('req_old')
|
|
1592
|
+
expect(h.io.errLines.join('\n')).toContain('previous waiter already used its ceiling')
|
|
1593
|
+
})
|
|
1594
|
+
|
|
1595
|
+
it('does not re-arm a live question written before owner leases were persisted', async () => {
|
|
1596
|
+
const h = harness([])
|
|
1597
|
+
h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
|
|
1598
|
+
writeSessionState('legacy-spent', h.env, {
|
|
1599
|
+
last_prompt_at: AWAY,
|
|
1600
|
+
pending: [
|
|
1601
|
+
{
|
|
1602
|
+
question: 'Degraded waiter leftover?',
|
|
1603
|
+
asked_at: NOW - 3_300_000,
|
|
1604
|
+
request_id: 'req_ATU4_legacy',
|
|
1605
|
+
collapse_key: 'question-legacy',
|
|
1606
|
+
device_ids: ['dev_iphone'],
|
|
1607
|
+
reply_deadline_at: NOW + 86_400_000,
|
|
1608
|
+
},
|
|
1609
|
+
],
|
|
1610
|
+
})
|
|
1611
|
+
|
|
1612
|
+
const started = h.deps.now?.() ?? NOW
|
|
1613
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'legacy-spent' }))
|
|
1614
|
+
|
|
1615
|
+
expect(h.deps.now?.()).toBeLessThan(started + 30_000)
|
|
1616
|
+
expect(readSessionState('legacy-spent', h.env).pending?.[0]?.request_id).toBe(
|
|
1617
|
+
'req_ATU4_legacy',
|
|
1618
|
+
)
|
|
1619
|
+
})
|
|
1620
|
+
|
|
1621
|
+
it('pushes a newly registered question on the next Stop after a spent waiter', async () => {
|
|
1622
|
+
const h = harness([])
|
|
1623
|
+
h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
|
|
1624
|
+
writeSessionState('after-degraded', h.env, {
|
|
1625
|
+
last_prompt_at: AWAY,
|
|
1626
|
+
pending: [
|
|
1627
|
+
{
|
|
1628
|
+
question: 'Earlier question?',
|
|
1629
|
+
asked_at: NOW - 3_300_000,
|
|
1630
|
+
request_id: 'req_earlier',
|
|
1631
|
+
collapse_key: 'question-earlier',
|
|
1632
|
+
device_ids: ['dev_iphone'],
|
|
1633
|
+
reply_deadline_at: NOW + 86_400_000,
|
|
1634
|
+
},
|
|
1635
|
+
],
|
|
1636
|
+
})
|
|
1637
|
+
|
|
1638
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'after-degraded' }))
|
|
1639
|
+
registerQuestion('after-degraded', h.env, { question: 'New question?' }, h.deps.now?.())
|
|
1640
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'after-degraded' }))
|
|
1641
|
+
|
|
1642
|
+
const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
|
|
1643
|
+
expect(questions.map((entry) => entry.draft.presentation.body)).toEqual(['New question?'])
|
|
1644
|
+
expect(readSessionState('after-degraded', h.env).pending?.map((entry) => entry.question)).toEqual(
|
|
1645
|
+
['Earlier question?', 'New question?'],
|
|
1646
|
+
)
|
|
1647
|
+
})
|
|
1648
|
+
|
|
1649
|
+
it('still waits out a live owner lease for an independent answer', async () => {
|
|
1650
|
+
const h = harness([])
|
|
1651
|
+
const answerAt = NOW + 10_000
|
|
1652
|
+
h.recorder.repliesFor = (requestId) =>
|
|
1653
|
+
requestId === 'req_live' && (h.deps.now?.() ?? 0) >= answerAt
|
|
1654
|
+
? [reply({ text: 'Later' })]
|
|
1655
|
+
: []
|
|
1656
|
+
writeSessionState('lease', h.env, {
|
|
1657
|
+
last_prompt_at: AWAY,
|
|
1658
|
+
pending: [
|
|
1659
|
+
{
|
|
1660
|
+
question: 'Still waiting?',
|
|
1661
|
+
asked_at: NOW,
|
|
1662
|
+
request_id: 'req_live',
|
|
1663
|
+
collapse_key: 'question-live',
|
|
1664
|
+
device_ids: ['dev_iphone'],
|
|
1665
|
+
reply_deadline_at: NOW + 86_400_000,
|
|
1666
|
+
owner_deadline_at: NOW + 60_000,
|
|
1667
|
+
},
|
|
1668
|
+
],
|
|
1669
|
+
})
|
|
1670
|
+
|
|
1671
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'lease' }))
|
|
1672
|
+
|
|
1673
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Later')
|
|
1674
|
+
expect(h.deps.now?.()).toBeGreaterThanOrEqual(answerAt)
|
|
1524
1675
|
})
|
|
1525
1676
|
})
|
|
1526
1677
|
|
|
@@ -1556,14 +1707,9 @@ describe('Cursor stop output', () => {
|
|
|
1556
1707
|
* questions piling up on a phone teach the user to ignore the surface.
|
|
1557
1708
|
*/
|
|
1558
1709
|
describe('question queueing and retirement debt', () => {
|
|
1559
|
-
/**
|
|
1560
|
-
function retirements(h: Harness):
|
|
1561
|
-
return h.recorder.
|
|
1562
|
-
.filter((s) => s.draft.event === 'question_retired')
|
|
1563
|
-
.map((s) => ({
|
|
1564
|
-
state: s.draft.lifecycle?.state,
|
|
1565
|
-
retires: s.draft.lifecycle?.retires_request_id,
|
|
1566
|
-
}))
|
|
1710
|
+
/** Request ids whose close fence proved retirement. */
|
|
1711
|
+
function retirements(h: Harness): string[] {
|
|
1712
|
+
return h.recorder.closed
|
|
1567
1713
|
}
|
|
1568
1714
|
|
|
1569
1715
|
function seedLive(h: Harness, sessionId: string, requestId = 'req_live'): void {
|
|
@@ -1603,13 +1749,17 @@ describe('question queueing and retirement debt', () => {
|
|
|
1603
1749
|
|
|
1604
1750
|
// Offline when the user returns: the wipe parks the retirement, the
|
|
1605
1751
|
// drain fails, and it must not forget what it was for.
|
|
1606
|
-
h.recorder.
|
|
1607
|
-
await hookRunCommand(
|
|
1752
|
+
h.recorder.failCloses = true
|
|
1753
|
+
await hookRunCommand(
|
|
1754
|
+
h.deps,
|
|
1755
|
+
'user-prompt-submit',
|
|
1756
|
+
stdin({ session_id: 'sup2', prompt: 'done' }),
|
|
1757
|
+
)
|
|
1608
1758
|
expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
|
|
1609
1759
|
|
|
1610
|
-
h.recorder.
|
|
1760
|
+
h.recorder.failCloses = false
|
|
1611
1761
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
|
|
1612
|
-
expect(retirements(h)).
|
|
1762
|
+
expect(retirements(h)).toContain(first)
|
|
1613
1763
|
expect(readSessionState('sup2', h.env).retiring).toEqual([])
|
|
1614
1764
|
})
|
|
1615
1765
|
|
|
@@ -1629,16 +1779,22 @@ describe('question queueing and retirement debt', () => {
|
|
|
1629
1779
|
const configDir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
1630
1780
|
mkdirSync(configDir, { recursive: true })
|
|
1631
1781
|
writeFileSync(path.join(configDir, 'config.toml'), 'devices = ["dev_iphone"]\n')
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1782
|
+
h.recorder.failCloses = true
|
|
1783
|
+
await hookRunCommand(
|
|
1784
|
+
h.deps,
|
|
1785
|
+
'user-prompt-submit',
|
|
1786
|
+
stdin({ session_id: 'sup-targets', prompt: 'done' }),
|
|
1636
1787
|
)
|
|
1637
|
-
|
|
1638
|
-
expect(
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1788
|
+
|
|
1789
|
+
expect(readSessionState('sup-targets', h.env).retiring).toEqual([
|
|
1790
|
+
expect.objectContaining({
|
|
1791
|
+
request_id: 'req_live',
|
|
1792
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
1793
|
+
}),
|
|
1794
|
+
])
|
|
1795
|
+
expect(
|
|
1796
|
+
h.recorder.submitted.filter((submission) => isRetirementSubmit(submission)),
|
|
1797
|
+
).toHaveLength(0)
|
|
1642
1798
|
})
|
|
1643
1799
|
|
|
1644
1800
|
it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
|
|
@@ -1649,14 +1805,18 @@ describe('question queueing and retirement debt', () => {
|
|
|
1649
1805
|
seedLive(h, 'sup3')
|
|
1650
1806
|
const first = 'req_live'
|
|
1651
1807
|
|
|
1652
|
-
h.recorder.
|
|
1653
|
-
await hookRunCommand(
|
|
1808
|
+
h.recorder.failCloses = true
|
|
1809
|
+
await hookRunCommand(
|
|
1810
|
+
h.deps,
|
|
1811
|
+
'user-prompt-submit',
|
|
1812
|
+
stdin({ session_id: 'sup3', prompt: 'done' }),
|
|
1813
|
+
)
|
|
1654
1814
|
expect(readSessionState('sup3', h.env).retiring).toHaveLength(1)
|
|
1655
|
-
h.recorder.
|
|
1815
|
+
h.recorder.failCloses = false
|
|
1656
1816
|
|
|
1657
1817
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3', stop_hook_active: true }))
|
|
1658
1818
|
|
|
1659
|
-
expect(retirements(h)).
|
|
1819
|
+
expect(retirements(h)).toContain(first)
|
|
1660
1820
|
expect(readSessionState('sup3', h.env).retiring).toEqual([])
|
|
1661
1821
|
})
|
|
1662
1822
|
|
|
@@ -1669,13 +1829,21 @@ describe('question queueing and retirement debt', () => {
|
|
|
1669
1829
|
|
|
1670
1830
|
// The first return is offline: the wipe parks, the drain fails, and the
|
|
1671
1831
|
// next prompt must still find the debt.
|
|
1672
|
-
h.recorder.
|
|
1673
|
-
await hookRunCommand(
|
|
1674
|
-
|
|
1832
|
+
h.recorder.failCloses = true
|
|
1833
|
+
await hookRunCommand(
|
|
1834
|
+
h.deps,
|
|
1835
|
+
'user-prompt-submit',
|
|
1836
|
+
stdin({ session_id: 'sup4', prompt: 'done' }),
|
|
1837
|
+
)
|
|
1838
|
+
h.recorder.failCloses = false
|
|
1675
1839
|
|
|
1676
|
-
await hookRunCommand(
|
|
1840
|
+
await hookRunCommand(
|
|
1841
|
+
h.deps,
|
|
1842
|
+
'user-prompt-submit',
|
|
1843
|
+
stdin({ session_id: 'sup4', prompt: 'ok' }),
|
|
1844
|
+
)
|
|
1677
1845
|
|
|
1678
|
-
expect(retirements(h)).
|
|
1846
|
+
expect(retirements(h)).toContain(first)
|
|
1679
1847
|
expect(readSessionState('sup4', h.env).retiring).toEqual([])
|
|
1680
1848
|
})
|
|
1681
1849
|
|
|
@@ -1709,7 +1877,7 @@ describe('several questions in flight', () => {
|
|
|
1709
1877
|
|
|
1710
1878
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi1' }))
|
|
1711
1879
|
|
|
1712
|
-
const questions = h.recorder.submitted.filter((s) => s
|
|
1880
|
+
const questions = h.recorder.submitted.filter((s) => isQuestionSubmit(s))
|
|
1713
1881
|
expect(questions.map((s) => s.draft.presentation.body)).toEqual(['Ship it?', 'Deploy where?'])
|
|
1714
1882
|
// Each is its own notification with its own collapse key — one ask never
|
|
1715
1883
|
// stands in for, or replaces, another.
|
|
@@ -1753,7 +1921,7 @@ describe('several questions in flight', () => {
|
|
|
1753
1921
|
'codex',
|
|
1754
1922
|
)
|
|
1755
1923
|
|
|
1756
|
-
expect(h.recorder.submitted.filter((s) => s
|
|
1924
|
+
expect(h.recorder.submitted.filter((s) => isQuestionSubmit(s))).toHaveLength(1)
|
|
1757
1925
|
expect(readSessionState('codex-form', h.env).pending).toBeUndefined()
|
|
1758
1926
|
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1759
1927
|
expect(h.io.errLines.join('\n')).toMatch(/expired with its continuation owner/)
|
|
@@ -1787,7 +1955,7 @@ describe('several questions in flight', () => {
|
|
|
1787
1955
|
|
|
1788
1956
|
// Submit recorded the durable id before any wait; a 500 must not erase it
|
|
1789
1957
|
// mid-flight, and recovery must still surface the late answer.
|
|
1790
|
-
const questions = h.recorder.submitted.filter((s) => s
|
|
1958
|
+
const questions = h.recorder.submitted.filter((s) => isQuestionSubmit(s))
|
|
1791
1959
|
expect(questions).toHaveLength(1)
|
|
1792
1960
|
expect(h.recorder.receipts[0]).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
|
|
1793
1961
|
expect(polls).toBeGreaterThanOrEqual(3)
|
|
@@ -1927,13 +2095,10 @@ describe('several questions in flight', () => {
|
|
|
1927
2095
|
)
|
|
1928
2096
|
expect(h.io.outLines).toEqual([])
|
|
1929
2097
|
expect(readSessionState('answer-handoff', h.env).accepted).toBeUndefined()
|
|
2098
|
+
expect(h.recorder.closed).toContain('req_existing')
|
|
1930
2099
|
expect(
|
|
1931
|
-
h.recorder.submitted.
|
|
1932
|
-
|
|
1933
|
-
entry.draft.event === 'question_retired' &&
|
|
1934
|
-
entry.draft.lifecycle?.retires_request_id === 'req_existing',
|
|
1935
|
-
)?.draft.lifecycle?.state,
|
|
1936
|
-
).toBe('answered')
|
|
2100
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
2101
|
+
).toHaveLength(0)
|
|
1937
2102
|
})
|
|
1938
2103
|
|
|
1939
2104
|
it('blocks Stop with the exact command until a required Agent Acknowledgement is recorded', async () => {
|
|
@@ -2123,19 +2288,14 @@ describe('several questions in flight', () => {
|
|
|
2123
2288
|
expect(followup.reason).toContain('Staging')
|
|
2124
2289
|
expect(readSessionState('multi3', h.env).pending).toBeUndefined()
|
|
2125
2290
|
expect(
|
|
2126
|
-
h.recorder.submitted.filter((s) => s
|
|
2291
|
+
h.recorder.submitted.filter((s) => isQuestionSubmit(s)),
|
|
2127
2292
|
).toHaveLength(2)
|
|
2128
2293
|
})
|
|
2129
2294
|
})
|
|
2130
2295
|
|
|
2131
2296
|
describe('a question that outlives its session', () => {
|
|
2132
|
-
function retirements(h: Harness):
|
|
2133
|
-
return h.recorder.
|
|
2134
|
-
.filter((s) => s.draft.event === 'question_retired')
|
|
2135
|
-
.map((s) => ({
|
|
2136
|
-
state: s.draft.lifecycle?.state,
|
|
2137
|
-
retires: s.draft.lifecycle?.retires_request_id,
|
|
2138
|
-
}))
|
|
2297
|
+
function retirements(h: Harness): string[] {
|
|
2298
|
+
return h.recorder.closed
|
|
2139
2299
|
}
|
|
2140
2300
|
|
|
2141
2301
|
/** State a still-owned request can have when SessionEnd races its Stop. */
|
|
@@ -2166,7 +2326,7 @@ describe('a question that outlives its session', () => {
|
|
|
2166
2326
|
// A different session's next hook holds a client and inherits the debt.
|
|
2167
2327
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'next1' }))
|
|
2168
2328
|
expect(h.recorder.closed).toContain(first)
|
|
2169
|
-
expect(retirements(h)).
|
|
2329
|
+
expect(retirements(h)).toContain(first)
|
|
2170
2330
|
})
|
|
2171
2331
|
|
|
2172
2332
|
it('carries parked retirements across SessionEnd too', async () => {
|
|
@@ -2175,13 +2335,17 @@ describe('a question that outlives its session', () => {
|
|
|
2175
2335
|
|
|
2176
2336
|
// Died while offline: the user came back, the wipe parked the
|
|
2177
2337
|
// retirement, and the drain could not send it.
|
|
2178
|
-
h.recorder.
|
|
2179
|
-
await hookRunCommand(
|
|
2180
|
-
|
|
2338
|
+
h.recorder.failCloses = true
|
|
2339
|
+
await hookRunCommand(
|
|
2340
|
+
h.deps,
|
|
2341
|
+
'user-prompt-submit',
|
|
2342
|
+
stdin({ session_id: 'dead2', prompt: 'done' }),
|
|
2343
|
+
)
|
|
2344
|
+
h.recorder.failCloses = false
|
|
2181
2345
|
|
|
2182
2346
|
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead2' }))
|
|
2183
2347
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next2' }))
|
|
2184
|
-
expect(retirements(h)).
|
|
2348
|
+
expect(retirements(h)).toContain(first)
|
|
2185
2349
|
})
|
|
2186
2350
|
|
|
2187
2351
|
it('keeps the debt when the drain fails, and drops entries past the TTL', async () => {
|
|
@@ -2190,16 +2354,16 @@ describe('a question that outlives its session', () => {
|
|
|
2190
2354
|
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead3' }))
|
|
2191
2355
|
|
|
2192
2356
|
// Still offline: the queue must survive a failed drain.
|
|
2193
|
-
h.recorder.
|
|
2357
|
+
h.recorder.failCloses = true
|
|
2194
2358
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
|
|
2195
|
-
h.recorder.
|
|
2359
|
+
h.recorder.failCloses = false
|
|
2196
2360
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
|
|
2197
|
-
expect(retirements(h)).
|
|
2361
|
+
expect(retirements(h)).toContain(first)
|
|
2198
2362
|
|
|
2199
2363
|
// And a second drain does not send it twice.
|
|
2200
|
-
const count = retirements(h).filter((
|
|
2364
|
+
const count = retirements(h).filter((id) => id === first).length
|
|
2201
2365
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
|
|
2202
|
-
expect(retirements(h).filter((
|
|
2366
|
+
expect(retirements(h).filter((id) => id === first)).toHaveLength(count)
|
|
2203
2367
|
})
|
|
2204
2368
|
|
|
2205
2369
|
it('gives up on an orphan that outlived the longest answerable window', async () => {
|
|
@@ -2323,9 +2487,9 @@ describe('ask registration', () => {
|
|
|
2323
2487
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'form1' }))
|
|
2324
2488
|
const draft = h.recorder.submitted[0]?.draft
|
|
2325
2489
|
expect(draft?.presentation.title).toBe('Deploy where?')
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
)
|
|
2490
|
+
// The questions travel structured and the first one is the title; the
|
|
2491
|
+
// body carries only the context so nothing renders twice.
|
|
2492
|
+
expect(draft?.presentation.body).toBe('## Context\nThe long story.')
|
|
2329
2493
|
expect(draft?.reply?.questions).toEqual([
|
|
2330
2494
|
{
|
|
2331
2495
|
id: 'deploy-where',
|
|
@@ -2466,7 +2630,347 @@ describe('ask registration', () => {
|
|
|
2466
2630
|
})
|
|
2467
2631
|
})
|
|
2468
2632
|
|
|
2633
|
+
describe('session-start hook', () => {
|
|
2634
|
+
it('adds proactive Notifai context before setup or machine authentication exists', async () => {
|
|
2635
|
+
const h = harness()
|
|
2636
|
+
h.deps.store.load = () => null
|
|
2637
|
+
|
|
2638
|
+
for (const hookHarness of ['claude-code', 'codex'] as const) {
|
|
2639
|
+
h.io.outLines = []
|
|
2640
|
+
await hookRunCommand(
|
|
2641
|
+
h.deps,
|
|
2642
|
+
'session-start',
|
|
2643
|
+
stdin({ session_id: `proactive-guidance-${hookHarness}`, source: 'startup' }),
|
|
2644
|
+
hookHarness,
|
|
2645
|
+
)
|
|
2646
|
+
|
|
2647
|
+
const first = JSON.parse(h.io.outLines.at(-1)!) as {
|
|
2648
|
+
hookSpecificOutput?: { hookEventName?: string; additionalContext?: string }
|
|
2649
|
+
}
|
|
2650
|
+
expect(first.hookSpecificOutput).toMatchObject({
|
|
2651
|
+
hookEventName: 'SessionStart',
|
|
2652
|
+
})
|
|
2653
|
+
expect(first.hookSpecificOutput?.additionalContext).toMatch(/Notifai.*session/i)
|
|
2654
|
+
expect(first.hookSpecificOutput?.additionalContext).toMatch(
|
|
2655
|
+
/before beginning task work.*use the Notifai skill.*run `notifai guidance`/i,
|
|
2656
|
+
)
|
|
2657
|
+
expect(first.hookSpecificOutput?.additionalContext).toMatch(/`notifai guidance`/i)
|
|
2658
|
+
expect(first.hookSpecificOutput?.additionalContext).toMatch(/did not mention/i)
|
|
2659
|
+
expect(h.io.errLines.join('\n')).not.toMatch(/not paired/i)
|
|
2660
|
+
}
|
|
2661
|
+
})
|
|
2662
|
+
|
|
2663
|
+
it('uses each supported lifecycle output contract and gives workers deterministic ownership', async () => {
|
|
2664
|
+
const h = harness()
|
|
2665
|
+
h.deps.store.load = () => null
|
|
2666
|
+
|
|
2667
|
+
await hookRunCommand(
|
|
2668
|
+
h.deps,
|
|
2669
|
+
'session-start',
|
|
2670
|
+
stdin({ conversation_id: 'cursor-session', workspace_roots: [h.deps.cwd] }),
|
|
2671
|
+
'cursor',
|
|
2672
|
+
)
|
|
2673
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
|
|
2674
|
+
additional_context: expect.stringMatching(/Notifai.*session/i),
|
|
2675
|
+
})
|
|
2676
|
+
|
|
2677
|
+
h.io.outLines = []
|
|
2678
|
+
await hookRunCommand(
|
|
2679
|
+
h.deps,
|
|
2680
|
+
'subagent-start',
|
|
2681
|
+
stdin({ session_id: 'subagent-session' }),
|
|
2682
|
+
'claude-code',
|
|
2683
|
+
)
|
|
2684
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
|
|
2685
|
+
hookSpecificOutput: {
|
|
2686
|
+
hookEventName: 'SubagentStart',
|
|
2687
|
+
additionalContext: expect.stringMatching(
|
|
2688
|
+
/parent owns.*unless it explicitly delegates.*workers.*do not send independently/i,
|
|
2689
|
+
),
|
|
2690
|
+
},
|
|
2691
|
+
})
|
|
2692
|
+
})
|
|
2693
|
+
|
|
2694
|
+
it('uses Cursor native Stop follow-up once when its SessionStart context is lossy', async () => {
|
|
2695
|
+
const h = harness()
|
|
2696
|
+
h.deps.store.load = () => null
|
|
2697
|
+
const input = stdin({
|
|
2698
|
+
conversation_id: 'cursor-activation',
|
|
2699
|
+
workspace_roots: [h.deps.cwd],
|
|
2700
|
+
loop_count: 0,
|
|
2701
|
+
})
|
|
2702
|
+
|
|
2703
|
+
await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
|
|
2704
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
|
|
2705
|
+
followup_message: expect.stringMatching(/before finalizing.*turn that just ended.*notifai guidance/i),
|
|
2706
|
+
})
|
|
2707
|
+
|
|
2708
|
+
h.io.outLines = []
|
|
2709
|
+
await hookRunCommand(
|
|
2710
|
+
h.deps,
|
|
2711
|
+
'activation-stop',
|
|
2712
|
+
stdin({
|
|
2713
|
+
conversation_id: 'cursor-activation',
|
|
2714
|
+
workspace_roots: [h.deps.cwd],
|
|
2715
|
+
loop_count: 1,
|
|
2716
|
+
}),
|
|
2717
|
+
'cursor',
|
|
2718
|
+
)
|
|
2719
|
+
expect(h.io.outLines).toEqual([])
|
|
2720
|
+
expect(readSessionState('cursor-activation', h.env).cursor_activation_confirmed_at).toBe(NOW)
|
|
2721
|
+
|
|
2722
|
+
await hookRunCommand(
|
|
2723
|
+
h.deps,
|
|
2724
|
+
'session-start',
|
|
2725
|
+
stdin({ conversation_id: 'cursor-activation', workspace_roots: [h.deps.cwd] }),
|
|
2726
|
+
'cursor',
|
|
2727
|
+
)
|
|
2728
|
+
h.io.outLines = []
|
|
2729
|
+
await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
|
|
2730
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
|
|
2731
|
+
})
|
|
2732
|
+
|
|
2733
|
+
it('does not let Cursor activation override cancellation or a live question continuation', async () => {
|
|
2734
|
+
const h = harness()
|
|
2735
|
+
const base = {
|
|
2736
|
+
conversation_id: 'cursor-coexistence',
|
|
2737
|
+
workspace_roots: [h.deps.cwd],
|
|
2738
|
+
loop_count: 0,
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
await hookRunCommand(
|
|
2742
|
+
h.deps,
|
|
2743
|
+
'activation-stop',
|
|
2744
|
+
stdin({ ...base, status: 'aborted' }),
|
|
2745
|
+
'cursor',
|
|
2746
|
+
)
|
|
2747
|
+
expect(h.io.outLines).toEqual([])
|
|
2748
|
+
|
|
2749
|
+
registerQuestion('cursor-coexistence', h.env, { question: 'Choose a path?' })
|
|
2750
|
+
await hookRunCommand(
|
|
2751
|
+
h.deps,
|
|
2752
|
+
'activation-stop',
|
|
2753
|
+
stdin({ ...base, status: 'completed' }),
|
|
2754
|
+
'cursor',
|
|
2755
|
+
)
|
|
2756
|
+
expect(h.io.outLines).toEqual([])
|
|
2757
|
+
expect(readSessionState('cursor-coexistence', h.env).cursor_activation_claimed_at).toBeUndefined()
|
|
2758
|
+
|
|
2759
|
+
await hookRunCommand(
|
|
2760
|
+
h.deps,
|
|
2761
|
+
'activation-stop',
|
|
2762
|
+
stdin({ ...base, status: 'completed', loop_count: 1 }),
|
|
2763
|
+
'cursor',
|
|
2764
|
+
)
|
|
2765
|
+
expect(h.io.outLines).toEqual([])
|
|
2766
|
+
expect(readSessionState('cursor-coexistence', h.env).cursor_activation_confirmed_at).toBeUndefined()
|
|
2767
|
+
})
|
|
2768
|
+
|
|
2769
|
+
it('retries an unconfirmed Cursor activation claim after the crash guard expires', async () => {
|
|
2770
|
+
const h = harness()
|
|
2771
|
+
const input = stdin({
|
|
2772
|
+
conversation_id: 'cursor-activation-retry',
|
|
2773
|
+
workspace_roots: [h.deps.cwd],
|
|
2774
|
+
status: 'completed',
|
|
2775
|
+
loop_count: 0,
|
|
2776
|
+
})
|
|
2777
|
+
|
|
2778
|
+
await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
|
|
2779
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
|
|
2780
|
+
|
|
2781
|
+
h.io.outLines = []
|
|
2782
|
+
await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
|
|
2783
|
+
expect(h.io.outLines).toEqual([])
|
|
2784
|
+
|
|
2785
|
+
h.advanceClock(30_001)
|
|
2786
|
+
await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
|
|
2787
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
|
|
2788
|
+
})
|
|
2789
|
+
|
|
2790
|
+
it('fails closed without a Cursor conversation id and still activates after an errored turn', async () => {
|
|
2791
|
+
const h = harness()
|
|
2792
|
+
|
|
2793
|
+
await hookRunCommand(
|
|
2794
|
+
h.deps,
|
|
2795
|
+
'activation-stop',
|
|
2796
|
+
stdin({ workspace_roots: [h.deps.cwd], status: 'completed', loop_count: 0 }),
|
|
2797
|
+
'cursor',
|
|
2798
|
+
)
|
|
2799
|
+
expect(h.io.outLines).toEqual([])
|
|
2800
|
+
|
|
2801
|
+
await hookRunCommand(
|
|
2802
|
+
h.deps,
|
|
2803
|
+
'activation-stop',
|
|
2804
|
+
stdin({
|
|
2805
|
+
conversation_id: 'cursor-error-activation',
|
|
2806
|
+
workspace_roots: [h.deps.cwd],
|
|
2807
|
+
status: 'error',
|
|
2808
|
+
loop_count: 0,
|
|
2809
|
+
}),
|
|
2810
|
+
'cursor',
|
|
2811
|
+
)
|
|
2812
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
|
|
2813
|
+
})
|
|
2814
|
+
|
|
2815
|
+
it('makes a Claude compatibility copy inside Cursor a no-op', async () => {
|
|
2816
|
+
const h = harness()
|
|
2817
|
+
h.deps.env['CURSOR_PROJECT_DIR'] = h.deps.cwd
|
|
2818
|
+
|
|
2819
|
+
await hookRunCommand(
|
|
2820
|
+
h.deps,
|
|
2821
|
+
'session-start',
|
|
2822
|
+
stdin({ session_id: 'cursor-compat-copy', source: 'startup' }),
|
|
2823
|
+
'claude-code',
|
|
2824
|
+
)
|
|
2825
|
+
|
|
2826
|
+
expect(h.io.outLines).toEqual([])
|
|
2827
|
+
})
|
|
2828
|
+
})
|
|
2829
|
+
|
|
2469
2830
|
describe('user-prompt-submit hook', () => {
|
|
2831
|
+
it('activates once as a compatibility fallback when SessionStart was absent', async () => {
|
|
2832
|
+
const h = harness()
|
|
2833
|
+
|
|
2834
|
+
await hookRunCommand(
|
|
2835
|
+
h.deps,
|
|
2836
|
+
'user-prompt-submit',
|
|
2837
|
+
stdin({ session_id: 'presence-only' }),
|
|
2838
|
+
'claude-code',
|
|
2839
|
+
)
|
|
2840
|
+
|
|
2841
|
+
expect(h.io.outLines).toHaveLength(1)
|
|
2842
|
+
expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
|
|
2843
|
+
hookSpecificOutput: {
|
|
2844
|
+
hookEventName: 'UserPromptSubmit',
|
|
2845
|
+
additionalContext: expect.stringMatching(/Notifai.*session/i),
|
|
2846
|
+
},
|
|
2847
|
+
})
|
|
2848
|
+
expect(readSessionState('presence-only', h.env).last_prompt_at).toBe(NOW)
|
|
2849
|
+
|
|
2850
|
+
await hookRunCommand(
|
|
2851
|
+
h.deps,
|
|
2852
|
+
'user-prompt-submit',
|
|
2853
|
+
stdin({ session_id: 'presence-only' }),
|
|
2854
|
+
'claude-code',
|
|
2855
|
+
)
|
|
2856
|
+
expect(h.io.outLines).toHaveLength(1)
|
|
2857
|
+
})
|
|
2858
|
+
|
|
2859
|
+
it('stays silent when SessionStart already delivered activation', async () => {
|
|
2860
|
+
const h = harness()
|
|
2861
|
+
h.deps.codexSourcePid = 4242
|
|
2862
|
+
|
|
2863
|
+
await hookRunCommand(
|
|
2864
|
+
h.deps,
|
|
2865
|
+
'session-start',
|
|
2866
|
+
stdin({ session_id: 'lifecycle-owned' }),
|
|
2867
|
+
'codex',
|
|
2868
|
+
)
|
|
2869
|
+
h.io.outLines = []
|
|
2870
|
+
|
|
2871
|
+
await hookRunCommand(
|
|
2872
|
+
h.deps,
|
|
2873
|
+
'user-prompt-submit',
|
|
2874
|
+
stdin({ session_id: 'lifecycle-owned' }),
|
|
2875
|
+
'codex',
|
|
2876
|
+
)
|
|
2877
|
+
|
|
2878
|
+
expect(h.io.outLines).toEqual([])
|
|
2879
|
+
})
|
|
2880
|
+
|
|
2881
|
+
it('reactivates a resumed session owned by a new harness process', async () => {
|
|
2882
|
+
const h = harness()
|
|
2883
|
+
h.deps.codexSourcePid = 4242
|
|
2884
|
+
await hookRunCommand(
|
|
2885
|
+
h.deps,
|
|
2886
|
+
'session-start',
|
|
2887
|
+
stdin({ session_id: 'resumed-owner' }),
|
|
2888
|
+
'codex',
|
|
2889
|
+
)
|
|
2890
|
+
h.io.outLines = []
|
|
2891
|
+
h.deps.codexSourcePid = 4343
|
|
2892
|
+
|
|
2893
|
+
await hookRunCommand(
|
|
2894
|
+
h.deps,
|
|
2895
|
+
'user-prompt-submit',
|
|
2896
|
+
stdin({ session_id: 'resumed-owner' }),
|
|
2897
|
+
'codex',
|
|
2898
|
+
)
|
|
2899
|
+
|
|
2900
|
+
expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
|
|
2901
|
+
hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
|
|
2902
|
+
})
|
|
2903
|
+
})
|
|
2904
|
+
|
|
2905
|
+
it('uses the adapter-propagated harness owner across per-hook CLI processes', async () => {
|
|
2906
|
+
const h = harness()
|
|
2907
|
+
h.env['NOTIFAI_HOOK_SOURCE_PID'] = '5252'
|
|
2908
|
+
|
|
2909
|
+
await hookRunCommand(
|
|
2910
|
+
h.deps,
|
|
2911
|
+
'user-prompt-submit',
|
|
2912
|
+
stdin({ session_id: 'adapter-owned' }),
|
|
2913
|
+
'codex',
|
|
2914
|
+
)
|
|
2915
|
+
h.io.outLines = []
|
|
2916
|
+
await hookRunCommand(
|
|
2917
|
+
h.deps,
|
|
2918
|
+
'user-prompt-submit',
|
|
2919
|
+
stdin({ session_id: 'adapter-owned' }),
|
|
2920
|
+
'codex',
|
|
2921
|
+
)
|
|
2922
|
+
expect(h.io.outLines).toEqual([])
|
|
2923
|
+
|
|
2924
|
+
h.env['NOTIFAI_HOOK_SOURCE_PID'] = '5353'
|
|
2925
|
+
await hookRunCommand(
|
|
2926
|
+
h.deps,
|
|
2927
|
+
'user-prompt-submit',
|
|
2928
|
+
stdin({ session_id: 'adapter-owned' }),
|
|
2929
|
+
'codex',
|
|
2930
|
+
)
|
|
2931
|
+
expect(h.io.outLines).toHaveLength(1)
|
|
2932
|
+
})
|
|
2933
|
+
|
|
2934
|
+
it('does not let a worker lifecycle suppress parent prompt activation', async () => {
|
|
2935
|
+
const h = harness()
|
|
2936
|
+
h.deps.codexSourcePid = 4242
|
|
2937
|
+
await hookRunCommand(
|
|
2938
|
+
h.deps,
|
|
2939
|
+
'subagent-start',
|
|
2940
|
+
stdin({ session_id: 'shared-parent-worker' }),
|
|
2941
|
+
'codex',
|
|
2942
|
+
)
|
|
2943
|
+
h.io.outLines = []
|
|
2944
|
+
|
|
2945
|
+
await hookRunCommand(
|
|
2946
|
+
h.deps,
|
|
2947
|
+
'user-prompt-submit',
|
|
2948
|
+
stdin({ session_id: 'shared-parent-worker' }),
|
|
2949
|
+
'codex',
|
|
2950
|
+
)
|
|
2951
|
+
|
|
2952
|
+
expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
|
|
2953
|
+
hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
|
|
2954
|
+
})
|
|
2955
|
+
})
|
|
2956
|
+
|
|
2957
|
+
it('activates before config and authentication readiness', async () => {
|
|
2958
|
+
const h = harness()
|
|
2959
|
+
h.deps.cwd = path.join(h.deps.cwd, 'missing-project')
|
|
2960
|
+
h.deps.store.load = () => null
|
|
2961
|
+
|
|
2962
|
+
await hookRunCommand(
|
|
2963
|
+
h.deps,
|
|
2964
|
+
'user-prompt-submit',
|
|
2965
|
+
stdin({ session_id: 'first-run', cwd: h.deps.cwd }),
|
|
2966
|
+
'codex',
|
|
2967
|
+
)
|
|
2968
|
+
|
|
2969
|
+
expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
|
|
2970
|
+
hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
|
|
2971
|
+
})
|
|
2972
|
+
})
|
|
2973
|
+
|
|
2470
2974
|
it('records presence', async () => {
|
|
2471
2975
|
const h = harness()
|
|
2472
2976
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's10' }))
|
|
@@ -2532,7 +3036,7 @@ describe('user-prompt-submit hook', () => {
|
|
|
2532
3036
|
expect(h.io.errLines.join('\n')).toMatch(/not paired|credential/i)
|
|
2533
3037
|
})
|
|
2534
3038
|
|
|
2535
|
-
it('preserves a delivered question through a second ask and
|
|
3039
|
+
it('preserves a delivered question through a second ask and an unrelated prompt', async () => {
|
|
2536
3040
|
const h = harness([])
|
|
2537
3041
|
const live = {
|
|
2538
3042
|
question: 'Ship it?',
|
|
@@ -2543,10 +3047,6 @@ describe('user-prompt-submit hook', () => {
|
|
|
2543
3047
|
}
|
|
2544
3048
|
writeSessionState('transition', h.env, { last_prompt_at: AWAY, pending: [live] })
|
|
2545
3049
|
|
|
2546
|
-
// A second plain `ask` resolves through the project pointer and joins the
|
|
2547
|
-
// queue behind the live question — it must not touch it. The next prompt
|
|
2548
|
-
// then resets presence before another Stop can run — the transition that
|
|
2549
|
-
// used to erase the retirement debt.
|
|
2550
3050
|
registerQuestion('transition', h.env, { question: 'Deploy it?' })
|
|
2551
3051
|
const queued = readSessionState('transition', h.env)
|
|
2552
3052
|
expect(queued.retiring ?? []).toEqual([])
|
|
@@ -2555,35 +3055,22 @@ describe('user-prompt-submit hook', () => {
|
|
|
2555
3055
|
await hookRunCommand(
|
|
2556
3056
|
h.deps,
|
|
2557
3057
|
'user-prompt-submit',
|
|
2558
|
-
stdin({
|
|
3058
|
+
stdin({
|
|
3059
|
+
session_id: 'transition',
|
|
3060
|
+
cwd: h.deps.cwd,
|
|
3061
|
+
prompt: 'keep going on the billing work',
|
|
3062
|
+
}),
|
|
2559
3063
|
'claude-code',
|
|
2560
3064
|
)
|
|
2561
3065
|
|
|
2562
|
-
|
|
2563
|
-
// one must remain queued: if Codex skipped Stop, erasing it here loses the
|
|
2564
|
-
// only copy before it ever reached a device.
|
|
2565
|
-
const retirement = h.recorder.submitted.find(
|
|
2566
|
-
(submission) => submission.draft.event === 'question_retired',
|
|
2567
|
-
)?.draft
|
|
2568
|
-
expect(retirement?.delivery.collapse_key).toBe(live.collapse_key)
|
|
2569
|
-
expect(retirement?.lifecycle).toEqual({
|
|
2570
|
-
tier: 'done',
|
|
2571
|
-
state: 'answered_elsewhere',
|
|
2572
|
-
retires_request_id: live.request_id,
|
|
2573
|
-
})
|
|
2574
|
-
expect(retirement?.targets).toEqual({
|
|
2575
|
-
mode: 'selected',
|
|
2576
|
-
device_ids: ['dev_iphone', 'dev_mac'],
|
|
2577
|
-
})
|
|
3066
|
+
expect(h.recorder.closed).toEqual([])
|
|
2578
3067
|
expect(
|
|
2579
|
-
h.recorder.submitted.filter((s) => s
|
|
2580
|
-
).toHaveLength(
|
|
3068
|
+
h.recorder.submitted.filter((s) => isRetirementSubmit(s)),
|
|
3069
|
+
).toHaveLength(0)
|
|
2581
3070
|
expect(readSessionState('transition', h.env).pending?.map((entry) => entry.question)).toEqual([
|
|
3071
|
+
'Ship it?',
|
|
2582
3072
|
'Deploy it?',
|
|
2583
3073
|
])
|
|
2584
|
-
|
|
2585
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
|
|
2586
|
-
expect(readSessionState('transition', h.env).pending).toBeUndefined()
|
|
2587
3074
|
})
|
|
2588
3075
|
|
|
2589
3076
|
it('retires a real timed-out Stop before a later prompt can accept an answer', async () => {
|
|
@@ -2593,14 +3080,14 @@ describe('user-prompt-submit hook', () => {
|
|
|
2593
3080
|
|
|
2594
3081
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
|
|
2595
3082
|
expect(readSessionState('s11', h.env).pending).toBeUndefined()
|
|
2596
|
-
|
|
2597
|
-
(entry) => entry.draft.event === 'question_retired',
|
|
2598
|
-
).length
|
|
3083
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
2599
3084
|
|
|
2600
3085
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's11', cwd: '/repo' }))
|
|
2601
3086
|
|
|
2602
3087
|
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
2603
|
-
expect(
|
|
3088
|
+
expect(
|
|
3089
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3090
|
+
).toHaveLength(0)
|
|
2604
3091
|
})
|
|
2605
3092
|
|
|
2606
3093
|
it('marks the retirement done/answered_elsewhere so it ships silently', async () => {
|
|
@@ -2619,16 +3106,16 @@ describe('user-prompt-submit hook', () => {
|
|
|
2619
3106
|
}],
|
|
2620
3107
|
})
|
|
2621
3108
|
|
|
2622
|
-
await hookRunCommand(
|
|
3109
|
+
await hookRunCommand(
|
|
3110
|
+
h.deps,
|
|
3111
|
+
'user-prompt-submit',
|
|
3112
|
+
stdin({ session_id: 's15', cwd: '/repo', prompt: 'done' }),
|
|
3113
|
+
)
|
|
2623
3114
|
|
|
2624
|
-
|
|
2625
|
-
expect(
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
// The history-entry correlation id: companions key entries by request
|
|
2629
|
-
// id and never persisted the collapse key.
|
|
2630
|
-
retires_request_id: 'req_live',
|
|
2631
|
-
})
|
|
3115
|
+
expect(h.recorder.closed).toContain('req_live')
|
|
3116
|
+
expect(
|
|
3117
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3118
|
+
).toHaveLength(0)
|
|
2632
3119
|
})
|
|
2633
3120
|
|
|
2634
3121
|
it('retires as done/answered when the answer came from a device', async () => {
|
|
@@ -2644,12 +3131,71 @@ describe('user-prompt-submit hook', () => {
|
|
|
2644
3131
|
stdin({ session_id: 's16', stop_hook_active: true }),
|
|
2645
3132
|
)
|
|
2646
3133
|
|
|
2647
|
-
|
|
2648
|
-
expect(
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
3134
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
3135
|
+
expect(
|
|
3136
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3137
|
+
).toHaveLength(0)
|
|
3138
|
+
})
|
|
3139
|
+
|
|
3140
|
+
it('retires only the outstanding question a prompt uniquely answers', async () => {
|
|
3141
|
+
const h = harness([])
|
|
3142
|
+
writeSessionState('prompt-match', h.env, {
|
|
3143
|
+
last_prompt_at: AWAY,
|
|
3144
|
+
pending: [
|
|
3145
|
+
{
|
|
3146
|
+
question: 'Ship it?',
|
|
3147
|
+
questions: [
|
|
3148
|
+
{
|
|
3149
|
+
id: 'q1',
|
|
3150
|
+
text: 'Ship it?',
|
|
3151
|
+
choices: [
|
|
3152
|
+
{ id: 'ship', label: 'Ship it' },
|
|
3153
|
+
{ id: 'wait', label: 'Wait' },
|
|
3154
|
+
],
|
|
3155
|
+
},
|
|
3156
|
+
],
|
|
3157
|
+
request_id: 'req_live',
|
|
3158
|
+
collapse_key: 'collapse-live',
|
|
3159
|
+
device_ids: ['dev_iphone'],
|
|
3160
|
+
reply_deadline_at: NOW + 60_000,
|
|
3161
|
+
},
|
|
3162
|
+
],
|
|
3163
|
+
})
|
|
3164
|
+
|
|
3165
|
+
await hookRunCommand(
|
|
3166
|
+
h.deps,
|
|
3167
|
+
'user-prompt-submit',
|
|
3168
|
+
stdin({ session_id: 'prompt-match', prompt: 'Ship it' }),
|
|
3169
|
+
)
|
|
3170
|
+
|
|
3171
|
+
expect(h.recorder.closed).toContain('req_live')
|
|
3172
|
+
expect(readSessionState('prompt-match', h.env).pending).toBeUndefined()
|
|
3173
|
+
})
|
|
3174
|
+
|
|
3175
|
+
it('leaves an unrelated prompt’s live question open for the reply window', async () => {
|
|
3176
|
+
const h = harness([])
|
|
3177
|
+
writeSessionState('prompt-unrelated', h.env, {
|
|
3178
|
+
last_prompt_at: AWAY,
|
|
3179
|
+
pending: [{
|
|
3180
|
+
question: 'Ship it?',
|
|
3181
|
+
request_id: 'req_live',
|
|
3182
|
+
collapse_key: 'collapse-live',
|
|
3183
|
+
device_ids: ['dev_iphone'],
|
|
3184
|
+
reply_deadline_at: NOW + 60_000,
|
|
3185
|
+
}],
|
|
2652
3186
|
})
|
|
3187
|
+
|
|
3188
|
+
await hookRunCommand(
|
|
3189
|
+
h.deps,
|
|
3190
|
+
'user-prompt-submit',
|
|
3191
|
+
stdin({
|
|
3192
|
+
session_id: 'prompt-unrelated',
|
|
3193
|
+
prompt: 'continue investigating the billing failure on staging',
|
|
3194
|
+
}),
|
|
3195
|
+
)
|
|
3196
|
+
|
|
3197
|
+
expect(h.recorder.closed).toEqual([])
|
|
3198
|
+
expect(readSessionState('prompt-unrelated', h.env).pending?.[0]?.request_id).toBe('req_live')
|
|
2653
3199
|
})
|
|
2654
3200
|
|
|
2655
3201
|
it('publishes a session pointer so a plain `notifai ask` can find itself', async () => {
|
|
@@ -2730,6 +3276,83 @@ describe('user-prompt-submit hook', () => {
|
|
|
2730
3276
|
})
|
|
2731
3277
|
})
|
|
2732
3278
|
|
|
3279
|
+
describe('reconciling a conversation answer', () => {
|
|
3280
|
+
it('matches a unique closed choice and a lone short confirmation', () => {
|
|
3281
|
+
const choiceQuestion: PendingQuestion = {
|
|
3282
|
+
question: 'Ship it?',
|
|
3283
|
+
questions: [
|
|
3284
|
+
{
|
|
3285
|
+
id: 'q1',
|
|
3286
|
+
text: 'Ship it?',
|
|
3287
|
+
choices: [
|
|
3288
|
+
{ id: 'ship', label: 'Ship it' },
|
|
3289
|
+
{ id: 'wait', label: 'Wait' },
|
|
3290
|
+
],
|
|
3291
|
+
},
|
|
3292
|
+
],
|
|
3293
|
+
}
|
|
3294
|
+
const freeText: PendingQuestion = { question: 'Create the key?' }
|
|
3295
|
+
expect(pendingAnsweredByPrompt('Ship it', [choiceQuestion]).map((entry) => entry.question)).toEqual([
|
|
3296
|
+
'Ship it?',
|
|
3297
|
+
])
|
|
3298
|
+
expect(pendingAnsweredByPrompt('done', [freeText])).toEqual([freeText])
|
|
3299
|
+
expect(pendingAnsweredByPrompt('done', [choiceQuestion, freeText])).toEqual([])
|
|
3300
|
+
expect(pendingAnsweredByPrompt('keep going on the billing work', [freeText])).toEqual([])
|
|
3301
|
+
})
|
|
3302
|
+
|
|
3303
|
+
it('does not push a conversation-answered question after a newer one is registered', async () => {
|
|
3304
|
+
const h = harness([])
|
|
3305
|
+
writeSessionState('stale-then-new', h.env, { last_prompt_at: AWAY })
|
|
3306
|
+
registerQuestion('stale-then-new', h.env, { question: 'Create the Apple IAP key?' })
|
|
3307
|
+
|
|
3308
|
+
await hookRunCommand(
|
|
3309
|
+
h.deps,
|
|
3310
|
+
'user-prompt-submit',
|
|
3311
|
+
stdin({ session_id: 'stale-then-new', prompt: 'done' }),
|
|
3312
|
+
)
|
|
3313
|
+
expect(readSessionState('stale-then-new', h.env).pending).toBeUndefined()
|
|
3314
|
+
|
|
3315
|
+
registerQuestion('stale-then-new', h.env, { question: 'Which ASN endpoint?' })
|
|
3316
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'stale-then-new' }))
|
|
3317
|
+
|
|
3318
|
+
const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
|
|
3319
|
+
expect(questions.map((entry) => entry.draft.presentation.body)).toEqual(['Which ASN endpoint?'])
|
|
3320
|
+
})
|
|
3321
|
+
|
|
3322
|
+
it('reserves a close-fence budget when the owner deadline is nearly now', async () => {
|
|
3323
|
+
const h = harness([])
|
|
3324
|
+
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
3325
|
+
mkdirSync(dir, { recursive: true })
|
|
3326
|
+
writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
|
|
3327
|
+
writeSessionState('near-zero', h.env, {
|
|
3328
|
+
last_prompt_at: AWAY,
|
|
3329
|
+
pending: [{
|
|
3330
|
+
question: 'Ship it?',
|
|
3331
|
+
asked_at: NOW - 60_000,
|
|
3332
|
+
request_id: 'req_live',
|
|
3333
|
+
collapse_key: 'collapse-live',
|
|
3334
|
+
device_ids: ['dev_iphone'],
|
|
3335
|
+
reply_deadline_at: NOW + 4_001,
|
|
3336
|
+
owner_deadline_at: NOW + 4_001,
|
|
3337
|
+
}],
|
|
3338
|
+
})
|
|
3339
|
+
|
|
3340
|
+
await hookRunCommand(
|
|
3341
|
+
{ ...h.deps, logger: createLogger({ env: h.env, cmd: 'hook stop' }) },
|
|
3342
|
+
'stop',
|
|
3343
|
+
stdin({ session_id: 'near-zero', cwd: h.deps.cwd }),
|
|
3344
|
+
)
|
|
3345
|
+
|
|
3346
|
+
expect(h.recorder.closed).toContain('req_live')
|
|
3347
|
+
expect(
|
|
3348
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3349
|
+
).toHaveLength(0)
|
|
3350
|
+
expect(h.deps.now?.()).toBeLessThan(NOW + 10_000)
|
|
3351
|
+
const records = readLogRecords(h.env, { event: ['hook.retirement'] }).records
|
|
3352
|
+
expect(records.some((record) => record.data?.['proven'] === true)).toBe(true)
|
|
3353
|
+
})
|
|
3354
|
+
})
|
|
3355
|
+
|
|
2733
3356
|
describe('session-end hook', () => {
|
|
2734
3357
|
it('drops local state without touching the network and records the lifecycle', async () => {
|
|
2735
3358
|
const h = harness()
|
|
@@ -2818,7 +3441,7 @@ describe('session-end hook', () => {
|
|
|
2818
3441
|
'the real Codex hook did not reach contended diagnostics before its deadline',
|
|
2819
3442
|
)
|
|
2820
3443
|
|
|
2821
|
-
// Reaching the second bakery ticket proves
|
|
3444
|
+
// Reaching the second bakery ticket proves program.ts preAction did not write
|
|
2822
3445
|
// first. Every durable cleanup must already be visible while diagnostics
|
|
2823
3446
|
// are still blocked behind the live holder.
|
|
2824
3447
|
expect(existsSync(stateFile)).toBe(false)
|
|
@@ -2997,7 +3620,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
2997
3620
|
'claude-code',
|
|
2998
3621
|
)
|
|
2999
3622
|
|
|
3000
|
-
const question = h.recorder.submitted.find((entry) => entry
|
|
3623
|
+
const question = h.recorder.submitted.find((entry) => isQuestionSubmit(entry))
|
|
3001
3624
|
expect(question?.draft.source).toEqual({
|
|
3002
3625
|
session_id: 'session-branch-transition',
|
|
3003
3626
|
session_label: 'Branch transition',
|
|
@@ -3019,15 +3642,10 @@ describe('telling concurrent agents apart', () => {
|
|
|
3019
3642
|
'claude-code',
|
|
3020
3643
|
)
|
|
3021
3644
|
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
session_id: 'session-branch-transition',
|
|
3027
|
-
session_label: 'Branch transition',
|
|
3028
|
-
harness: 'claude-code',
|
|
3029
|
-
branch: 'branch-a',
|
|
3030
|
-
})
|
|
3645
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
3646
|
+
expect(
|
|
3647
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3648
|
+
).toHaveLength(0)
|
|
3031
3649
|
})
|
|
3032
3650
|
|
|
3033
3651
|
it('clears registration-time branch and worktree on a detached-HEAD hook event', async () => {
|
|
@@ -3057,7 +3675,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
3057
3675
|
'claude-code',
|
|
3058
3676
|
)
|
|
3059
3677
|
|
|
3060
|
-
const question = h.recorder.submitted.find((entry) => entry
|
|
3678
|
+
const question = h.recorder.submitted.find((entry) => isQuestionSubmit(entry))
|
|
3061
3679
|
expect(question?.draft.source).toEqual({
|
|
3062
3680
|
session_id: 'session-detached',
|
|
3063
3681
|
session_label: 'Detached review',
|
|
@@ -3093,7 +3711,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
3093
3711
|
'claude-code',
|
|
3094
3712
|
)
|
|
3095
3713
|
const question = h.recorder.submitted.find(
|
|
3096
|
-
(entry) => entry
|
|
3714
|
+
(entry) => isQuestionSubmit(entry),
|
|
3097
3715
|
)
|
|
3098
3716
|
expect(question?.draft.source).toEqual({
|
|
3099
3717
|
session_id: 'session-worktree-transition',
|
|
@@ -3115,15 +3733,10 @@ describe('telling concurrent agents apart', () => {
|
|
|
3115
3733
|
}),
|
|
3116
3734
|
'claude-code',
|
|
3117
3735
|
)
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
session_id: 'session-worktree-transition',
|
|
3123
|
-
session_label: 'Worktree transition',
|
|
3124
|
-
harness: 'claude-code',
|
|
3125
|
-
branch: 'main',
|
|
3126
|
-
})
|
|
3736
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
3737
|
+
expect(
|
|
3738
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3739
|
+
).toHaveLength(0)
|
|
3127
3740
|
} finally {
|
|
3128
3741
|
runFixtureGit(h.deps.cwd, 'worktree', 'remove', '--force', linked)
|
|
3129
3742
|
}
|
|
@@ -3179,12 +3792,10 @@ describe('telling concurrent agents apart', () => {
|
|
|
3179
3792
|
stdin({ session_id: 'sess-abc', stop_hook_active: true }),
|
|
3180
3793
|
)
|
|
3181
3794
|
|
|
3182
|
-
|
|
3183
|
-
expect(
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
harness: 'claude-code',
|
|
3187
|
-
})
|
|
3795
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
3796
|
+
expect(
|
|
3797
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3798
|
+
).toHaveLength(0)
|
|
3188
3799
|
})
|
|
3189
3800
|
|
|
3190
3801
|
it('preserves a human session label without displaying the opaque id', async () => {
|
|
@@ -3362,7 +3973,7 @@ describe('durable state writes', () => {
|
|
|
3362
3973
|
expect(readFileSync(queueTarget, 'utf8')).toBe('[]\n')
|
|
3363
3974
|
})
|
|
3364
3975
|
|
|
3365
|
-
it('persists each successful retirement before a later
|
|
3976
|
+
it('persists each successful retirement before a later close failure interrupts the drain', async () => {
|
|
3366
3977
|
const h = harness()
|
|
3367
3978
|
writeSessionState('partial-drain', h.env, {
|
|
3368
3979
|
retiring: [
|
|
@@ -3376,24 +3987,30 @@ describe('durable state writes', () => {
|
|
|
3376
3987
|
{
|
|
3377
3988
|
request_id: 'req_bad',
|
|
3378
3989
|
collapse_key: 'collapse-bad',
|
|
3379
|
-
device_ids: [],
|
|
3990
|
+
device_ids: ['dev_iphone'],
|
|
3380
3991
|
question: 'Second?',
|
|
3381
3992
|
state: 'expired',
|
|
3382
3993
|
},
|
|
3383
3994
|
],
|
|
3384
3995
|
})
|
|
3996
|
+
const base = h.deps.clientFactory!('https://test.notifai.invalid', 'Bearer x')
|
|
3385
3997
|
const ctx = {
|
|
3386
|
-
client:
|
|
3998
|
+
client: {
|
|
3999
|
+
...base,
|
|
4000
|
+
closeReplies: async (requestId: string) => {
|
|
4001
|
+
if (requestId === 'req_bad') throw new Error('offline')
|
|
4002
|
+
return base.closeReplies(requestId)
|
|
4003
|
+
},
|
|
4004
|
+
},
|
|
3387
4005
|
config: loadConfig({ cwd: h.deps.cwd, env: h.env }),
|
|
3388
4006
|
}
|
|
3389
4007
|
|
|
3390
|
-
await
|
|
3391
|
-
/missing its device identifiers/,
|
|
3392
|
-
)
|
|
4008
|
+
await drainRetirements(ctx, 'partial-drain', h.env)
|
|
3393
4009
|
|
|
3394
4010
|
expect(readSessionState('partial-drain', h.env).retiring?.map((entry) => entry.request_id)).toEqual([
|
|
3395
4011
|
'req_bad',
|
|
3396
4012
|
])
|
|
4013
|
+
expect(h.recorder.closed).toEqual(['req_good'])
|
|
3397
4014
|
})
|
|
3398
4015
|
})
|
|
3399
4016
|
|
|
@@ -3716,14 +4333,10 @@ describe('Claude Code Stop wake route', () => {
|
|
|
3716
4333
|
expect(wake.sent).toHaveLength(1)
|
|
3717
4334
|
expect(wake.resumed).toEqual([])
|
|
3718
4335
|
expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
|
|
3719
|
-
|
|
4336
|
+
expect(h.recorder.closed).toContain('req_existing')
|
|
3720
4337
|
expect(
|
|
3721
|
-
h.recorder.submitted.
|
|
3722
|
-
|
|
3723
|
-
entry.draft.event === 'question_retired' &&
|
|
3724
|
-
entry.draft.lifecycle?.retires_request_id === 'req_existing',
|
|
3725
|
-
)?.draft.lifecycle?.state,
|
|
3726
|
-
).toBe('answered')
|
|
4338
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
4339
|
+
).toHaveLength(0)
|
|
3727
4340
|
})
|
|
3728
4341
|
|
|
3729
4342
|
it('applies the continuation cap to a woken turn that never sets stop_hook_active', async () => {
|
|
@@ -3751,7 +4364,7 @@ describe('Claude Code Stop wake route', () => {
|
|
|
3751
4364
|
'claude-code',
|
|
3752
4365
|
)
|
|
3753
4366
|
|
|
3754
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
4367
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toEqual(
|
|
3755
4368
|
[],
|
|
3756
4369
|
)
|
|
3757
4370
|
expect(readSessionState('claude-route', h.env).pending?.[0]?.question).toBe('And after that?')
|