@raidiant/notifai 8.0.0 → 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 +41 -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-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +71 -15
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +63 -28
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-hooks.d.ts +9 -4
- package/dist/commands-hooks.js +430 -32
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +114 -32
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +61 -5
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +60 -11
- 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 +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +25 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -3
- package/dist/config.js +37 -5
- 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 +20 -0
- package/dist/guidance-content.js +73 -8
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance.d.ts +21 -0
- package/dist/guidance.js +73 -18
- package/dist/guidance.js.map +1 -1
- 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 +231 -85
- 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 +1 -1
- 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 +2 -13
- 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.js +13 -8
- package/dist/program.js.map +1 -1
- package/dist/send.d.ts +8 -1
- package/dist/send.js +17 -1
- 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/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +3 -3
- 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-doctor.ts +99 -13
- package/src/commands-guidance.ts +75 -29
- package/src/commands-hooks.ts +503 -34
- package/src/commands-init.ts +139 -38
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +73 -10
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1083 -101
- package/src/commands.ts +1 -0
- package/src/config-schema.ts +28 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +49 -5
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +74 -8
- package/src/guidance.test.ts +42 -11
- package/src/guidance.ts +100 -18
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +746 -165
- package/src/hooks.ts +299 -124
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +1 -1
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +2 -14
- 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 +37 -2
- package/src/program.ts +40 -12
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +45 -0
- package/src/skill.test.ts +120 -4
- 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[] => {
|
|
@@ -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,
|
|
@@ -736,7 +750,7 @@ describe('what the hook leaves behind', () => {
|
|
|
736
750
|
)
|
|
737
751
|
|
|
738
752
|
const raw = readFileSync(activeLogPath(h.env), 'utf8')
|
|
739
|
-
expect(raw.
|
|
753
|
+
expect(raw).not.toContain('Allow exactly once')
|
|
740
754
|
const answerRecords = readLogRecords(h.env, { event: ['hook.answer'] }).records
|
|
741
755
|
expect(answerRecords.map((record) => record.data?.['stage'])).toEqual([
|
|
742
756
|
'queued',
|
|
@@ -867,7 +881,7 @@ describe('terminal-first grace window', () => {
|
|
|
867
881
|
|
|
868
882
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present1' }))
|
|
869
883
|
|
|
870
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
884
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
|
|
871
885
|
expect(submittedAt).toBe(NOW)
|
|
872
886
|
})
|
|
873
887
|
|
|
@@ -900,7 +914,7 @@ describe('terminal-first grace window', () => {
|
|
|
900
914
|
|
|
901
915
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
|
|
902
916
|
|
|
903
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
917
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
|
|
904
918
|
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
|
|
905
919
|
})
|
|
906
920
|
|
|
@@ -929,7 +943,7 @@ describe('terminal-first grace window', () => {
|
|
|
929
943
|
expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 360_000)
|
|
930
944
|
expect(h.deps.now?.()).toBeLessThan(NOW + 361_000)
|
|
931
945
|
expect(
|
|
932
|
-
h.recorder.submitted.find((entry) => entry
|
|
946
|
+
h.recorder.submitted.find((entry) => isQuestionSubmit(entry))?.draft.reply
|
|
933
947
|
?.expires_in_seconds,
|
|
934
948
|
).toBeGreaterThanOrEqual(60)
|
|
935
949
|
})
|
|
@@ -1038,7 +1052,7 @@ describe('nagging guards', () => {
|
|
|
1038
1052
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
|
|
1039
1053
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
|
|
1040
1054
|
|
|
1041
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
1055
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
|
|
1042
1056
|
})
|
|
1043
1057
|
|
|
1044
1058
|
it('respects the harness recursion guard', async () => {
|
|
@@ -1066,7 +1080,7 @@ describe('nagging guards', () => {
|
|
|
1066
1080
|
|
|
1067
1081
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3', stop_hook_active: true }))
|
|
1068
1082
|
|
|
1069
|
-
const questions = h.recorder.submitted.filter((entry) => entry
|
|
1083
|
+
const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
|
|
1070
1084
|
expect(questions.map((entry) => entry.draft.presentation.body)).toEqual([
|
|
1071
1085
|
'First question?',
|
|
1072
1086
|
'Follow-up question?',
|
|
@@ -1123,7 +1137,7 @@ describe('late answer collection', () => {
|
|
|
1123
1137
|
|
|
1124
1138
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
|
|
1125
1139
|
|
|
1126
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
1140
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
|
|
1127
1141
|
expect(h.recorder.closed).toContain('req_live')
|
|
1128
1142
|
expect(readSessionState('late-stop', h.env).pending).toBeUndefined()
|
|
1129
1143
|
expect(h.io.outLines).toHaveLength(1)
|
|
@@ -1170,14 +1184,10 @@ describe('late answer collection', () => {
|
|
|
1170
1184
|
stdin({ session_id: 'late-prompt', stop_hook_active: true }),
|
|
1171
1185
|
)
|
|
1172
1186
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
state: 'answered',
|
|
1178
|
-
retires_request_id: 'req_live',
|
|
1179
|
-
})
|
|
1180
|
-
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)
|
|
1181
1191
|
expect(readSessionState('late-prompt', h.env).pending).toBeUndefined()
|
|
1182
1192
|
expect(readSessionState('late-prompt', h.env).accepted).toBeUndefined()
|
|
1183
1193
|
})
|
|
@@ -1191,10 +1201,10 @@ describe('OpenCode answer continuation', () => {
|
|
|
1191
1201
|
|
|
1192
1202
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'open1' }), 'opencode')
|
|
1193
1203
|
|
|
1194
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
1204
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
|
|
1195
1205
|
expect(h.recorder.closed).toEqual([])
|
|
1196
1206
|
expect(
|
|
1197
|
-
h.recorder.submitted.filter((entry) => entry
|
|
1207
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
1198
1208
|
).toHaveLength(0)
|
|
1199
1209
|
expect(readSessionState('open1', h.env).pending).toBeUndefined()
|
|
1200
1210
|
expect(h.io.outLines).toEqual([])
|
|
@@ -1268,7 +1278,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1268
1278
|
return {
|
|
1269
1279
|
...client,
|
|
1270
1280
|
submit: async (body: SubmitNotificationRequestT) => {
|
|
1271
|
-
if (body.draft.
|
|
1281
|
+
if (body.draft.reply === undefined) return client.submit(body, 0)
|
|
1272
1282
|
acceptedRequestId = body.request_id
|
|
1273
1283
|
h.recorder.submitted.push(body)
|
|
1274
1284
|
h.recorder.receipts.push(body.request_id!)
|
|
@@ -1496,7 +1506,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1496
1506
|
|
|
1497
1507
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline' }))
|
|
1498
1508
|
|
|
1499
|
-
expect(h.deps.now?.()).toBe(NOW +
|
|
1509
|
+
expect(h.deps.now?.()).toBe(NOW + 57_000)
|
|
1500
1510
|
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1501
1511
|
expect(readSessionState('deadline', h.env).pending).toBeUndefined()
|
|
1502
1512
|
expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
|
|
@@ -1512,7 +1522,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1512
1522
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latency' }))
|
|
1513
1523
|
|
|
1514
1524
|
const windows = h.recorder.submitted
|
|
1515
|
-
.filter((entry) => entry
|
|
1525
|
+
.filter((entry) => isQuestionSubmit(entry))
|
|
1516
1526
|
.map((entry) => entry.draft.reply?.expires_in_seconds)
|
|
1517
1527
|
// How long the user may answer does not shrink because this owner spent
|
|
1518
1528
|
// part of its own budget getting the question out. Both questions stay
|
|
@@ -1552,7 +1562,116 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1552
1562
|
|
|
1553
1563
|
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Second answer')
|
|
1554
1564
|
expect(readSessionState('successor', h.env).pending).toBeUndefined()
|
|
1555
|
-
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)
|
|
1556
1675
|
})
|
|
1557
1676
|
})
|
|
1558
1677
|
|
|
@@ -1588,14 +1707,9 @@ describe('Cursor stop output', () => {
|
|
|
1588
1707
|
* questions piling up on a phone teach the user to ignore the surface.
|
|
1589
1708
|
*/
|
|
1590
1709
|
describe('question queueing and retirement debt', () => {
|
|
1591
|
-
/**
|
|
1592
|
-
function retirements(h: Harness):
|
|
1593
|
-
return h.recorder.
|
|
1594
|
-
.filter((s) => s.draft.event === 'question_retired')
|
|
1595
|
-
.map((s) => ({
|
|
1596
|
-
state: s.draft.lifecycle?.state,
|
|
1597
|
-
retires: s.draft.lifecycle?.retires_request_id,
|
|
1598
|
-
}))
|
|
1710
|
+
/** Request ids whose close fence proved retirement. */
|
|
1711
|
+
function retirements(h: Harness): string[] {
|
|
1712
|
+
return h.recorder.closed
|
|
1599
1713
|
}
|
|
1600
1714
|
|
|
1601
1715
|
function seedLive(h: Harness, sessionId: string, requestId = 'req_live'): void {
|
|
@@ -1635,13 +1749,17 @@ describe('question queueing and retirement debt', () => {
|
|
|
1635
1749
|
|
|
1636
1750
|
// Offline when the user returns: the wipe parks the retirement, the
|
|
1637
1751
|
// drain fails, and it must not forget what it was for.
|
|
1638
|
-
h.recorder.
|
|
1639
|
-
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
|
+
)
|
|
1640
1758
|
expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
|
|
1641
1759
|
|
|
1642
|
-
h.recorder.
|
|
1760
|
+
h.recorder.failCloses = false
|
|
1643
1761
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
|
|
1644
|
-
expect(retirements(h)).
|
|
1762
|
+
expect(retirements(h)).toContain(first)
|
|
1645
1763
|
expect(readSessionState('sup2', h.env).retiring).toEqual([])
|
|
1646
1764
|
})
|
|
1647
1765
|
|
|
@@ -1661,16 +1779,22 @@ describe('question queueing and retirement debt', () => {
|
|
|
1661
1779
|
const configDir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
1662
1780
|
mkdirSync(configDir, { recursive: true })
|
|
1663
1781
|
writeFileSync(path.join(configDir, 'config.toml'), 'devices = ["dev_iphone"]\n')
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1782
|
+
h.recorder.failCloses = true
|
|
1783
|
+
await hookRunCommand(
|
|
1784
|
+
h.deps,
|
|
1785
|
+
'user-prompt-submit',
|
|
1786
|
+
stdin({ session_id: 'sup-targets', prompt: 'done' }),
|
|
1668
1787
|
)
|
|
1669
|
-
|
|
1670
|
-
expect(
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
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)
|
|
1674
1798
|
})
|
|
1675
1799
|
|
|
1676
1800
|
it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
|
|
@@ -1681,14 +1805,18 @@ describe('question queueing and retirement debt', () => {
|
|
|
1681
1805
|
seedLive(h, 'sup3')
|
|
1682
1806
|
const first = 'req_live'
|
|
1683
1807
|
|
|
1684
|
-
h.recorder.
|
|
1685
|
-
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
|
+
)
|
|
1686
1814
|
expect(readSessionState('sup3', h.env).retiring).toHaveLength(1)
|
|
1687
|
-
h.recorder.
|
|
1815
|
+
h.recorder.failCloses = false
|
|
1688
1816
|
|
|
1689
1817
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3', stop_hook_active: true }))
|
|
1690
1818
|
|
|
1691
|
-
expect(retirements(h)).
|
|
1819
|
+
expect(retirements(h)).toContain(first)
|
|
1692
1820
|
expect(readSessionState('sup3', h.env).retiring).toEqual([])
|
|
1693
1821
|
})
|
|
1694
1822
|
|
|
@@ -1701,13 +1829,21 @@ describe('question queueing and retirement debt', () => {
|
|
|
1701
1829
|
|
|
1702
1830
|
// The first return is offline: the wipe parks, the drain fails, and the
|
|
1703
1831
|
// next prompt must still find the debt.
|
|
1704
|
-
h.recorder.
|
|
1705
|
-
await hookRunCommand(
|
|
1706
|
-
|
|
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
|
|
1707
1839
|
|
|
1708
|
-
await hookRunCommand(
|
|
1840
|
+
await hookRunCommand(
|
|
1841
|
+
h.deps,
|
|
1842
|
+
'user-prompt-submit',
|
|
1843
|
+
stdin({ session_id: 'sup4', prompt: 'ok' }),
|
|
1844
|
+
)
|
|
1709
1845
|
|
|
1710
|
-
expect(retirements(h)).
|
|
1846
|
+
expect(retirements(h)).toContain(first)
|
|
1711
1847
|
expect(readSessionState('sup4', h.env).retiring).toEqual([])
|
|
1712
1848
|
})
|
|
1713
1849
|
|
|
@@ -1741,7 +1877,7 @@ describe('several questions in flight', () => {
|
|
|
1741
1877
|
|
|
1742
1878
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi1' }))
|
|
1743
1879
|
|
|
1744
|
-
const questions = h.recorder.submitted.filter((s) => s
|
|
1880
|
+
const questions = h.recorder.submitted.filter((s) => isQuestionSubmit(s))
|
|
1745
1881
|
expect(questions.map((s) => s.draft.presentation.body)).toEqual(['Ship it?', 'Deploy where?'])
|
|
1746
1882
|
// Each is its own notification with its own collapse key — one ask never
|
|
1747
1883
|
// stands in for, or replaces, another.
|
|
@@ -1785,7 +1921,7 @@ describe('several questions in flight', () => {
|
|
|
1785
1921
|
'codex',
|
|
1786
1922
|
)
|
|
1787
1923
|
|
|
1788
|
-
expect(h.recorder.submitted.filter((s) => s
|
|
1924
|
+
expect(h.recorder.submitted.filter((s) => isQuestionSubmit(s))).toHaveLength(1)
|
|
1789
1925
|
expect(readSessionState('codex-form', h.env).pending).toBeUndefined()
|
|
1790
1926
|
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1791
1927
|
expect(h.io.errLines.join('\n')).toMatch(/expired with its continuation owner/)
|
|
@@ -1819,7 +1955,7 @@ describe('several questions in flight', () => {
|
|
|
1819
1955
|
|
|
1820
1956
|
// Submit recorded the durable id before any wait; a 500 must not erase it
|
|
1821
1957
|
// mid-flight, and recovery must still surface the late answer.
|
|
1822
|
-
const questions = h.recorder.submitted.filter((s) => s
|
|
1958
|
+
const questions = h.recorder.submitted.filter((s) => isQuestionSubmit(s))
|
|
1823
1959
|
expect(questions).toHaveLength(1)
|
|
1824
1960
|
expect(h.recorder.receipts[0]).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
|
|
1825
1961
|
expect(polls).toBeGreaterThanOrEqual(3)
|
|
@@ -1959,13 +2095,10 @@ describe('several questions in flight', () => {
|
|
|
1959
2095
|
)
|
|
1960
2096
|
expect(h.io.outLines).toEqual([])
|
|
1961
2097
|
expect(readSessionState('answer-handoff', h.env).accepted).toBeUndefined()
|
|
2098
|
+
expect(h.recorder.closed).toContain('req_existing')
|
|
1962
2099
|
expect(
|
|
1963
|
-
h.recorder.submitted.
|
|
1964
|
-
|
|
1965
|
-
entry.draft.event === 'question_retired' &&
|
|
1966
|
-
entry.draft.lifecycle?.retires_request_id === 'req_existing',
|
|
1967
|
-
)?.draft.lifecycle?.state,
|
|
1968
|
-
).toBe('answered')
|
|
2100
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
2101
|
+
).toHaveLength(0)
|
|
1969
2102
|
})
|
|
1970
2103
|
|
|
1971
2104
|
it('blocks Stop with the exact command until a required Agent Acknowledgement is recorded', async () => {
|
|
@@ -2155,19 +2288,14 @@ describe('several questions in flight', () => {
|
|
|
2155
2288
|
expect(followup.reason).toContain('Staging')
|
|
2156
2289
|
expect(readSessionState('multi3', h.env).pending).toBeUndefined()
|
|
2157
2290
|
expect(
|
|
2158
|
-
h.recorder.submitted.filter((s) => s
|
|
2291
|
+
h.recorder.submitted.filter((s) => isQuestionSubmit(s)),
|
|
2159
2292
|
).toHaveLength(2)
|
|
2160
2293
|
})
|
|
2161
2294
|
})
|
|
2162
2295
|
|
|
2163
2296
|
describe('a question that outlives its session', () => {
|
|
2164
|
-
function retirements(h: Harness):
|
|
2165
|
-
return h.recorder.
|
|
2166
|
-
.filter((s) => s.draft.event === 'question_retired')
|
|
2167
|
-
.map((s) => ({
|
|
2168
|
-
state: s.draft.lifecycle?.state,
|
|
2169
|
-
retires: s.draft.lifecycle?.retires_request_id,
|
|
2170
|
-
}))
|
|
2297
|
+
function retirements(h: Harness): string[] {
|
|
2298
|
+
return h.recorder.closed
|
|
2171
2299
|
}
|
|
2172
2300
|
|
|
2173
2301
|
/** State a still-owned request can have when SessionEnd races its Stop. */
|
|
@@ -2198,7 +2326,7 @@ describe('a question that outlives its session', () => {
|
|
|
2198
2326
|
// A different session's next hook holds a client and inherits the debt.
|
|
2199
2327
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'next1' }))
|
|
2200
2328
|
expect(h.recorder.closed).toContain(first)
|
|
2201
|
-
expect(retirements(h)).
|
|
2329
|
+
expect(retirements(h)).toContain(first)
|
|
2202
2330
|
})
|
|
2203
2331
|
|
|
2204
2332
|
it('carries parked retirements across SessionEnd too', async () => {
|
|
@@ -2207,13 +2335,17 @@ describe('a question that outlives its session', () => {
|
|
|
2207
2335
|
|
|
2208
2336
|
// Died while offline: the user came back, the wipe parked the
|
|
2209
2337
|
// retirement, and the drain could not send it.
|
|
2210
|
-
h.recorder.
|
|
2211
|
-
await hookRunCommand(
|
|
2212
|
-
|
|
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
|
|
2213
2345
|
|
|
2214
2346
|
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead2' }))
|
|
2215
2347
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next2' }))
|
|
2216
|
-
expect(retirements(h)).
|
|
2348
|
+
expect(retirements(h)).toContain(first)
|
|
2217
2349
|
})
|
|
2218
2350
|
|
|
2219
2351
|
it('keeps the debt when the drain fails, and drops entries past the TTL', async () => {
|
|
@@ -2222,16 +2354,16 @@ describe('a question that outlives its session', () => {
|
|
|
2222
2354
|
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead3' }))
|
|
2223
2355
|
|
|
2224
2356
|
// Still offline: the queue must survive a failed drain.
|
|
2225
|
-
h.recorder.
|
|
2357
|
+
h.recorder.failCloses = true
|
|
2226
2358
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
|
|
2227
|
-
h.recorder.
|
|
2359
|
+
h.recorder.failCloses = false
|
|
2228
2360
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
|
|
2229
|
-
expect(retirements(h)).
|
|
2361
|
+
expect(retirements(h)).toContain(first)
|
|
2230
2362
|
|
|
2231
2363
|
// And a second drain does not send it twice.
|
|
2232
|
-
const count = retirements(h).filter((
|
|
2364
|
+
const count = retirements(h).filter((id) => id === first).length
|
|
2233
2365
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
|
|
2234
|
-
expect(retirements(h).filter((
|
|
2366
|
+
expect(retirements(h).filter((id) => id === first)).toHaveLength(count)
|
|
2235
2367
|
})
|
|
2236
2368
|
|
|
2237
2369
|
it('gives up on an orphan that outlived the longest answerable window', async () => {
|
|
@@ -2498,7 +2630,347 @@ describe('ask registration', () => {
|
|
|
2498
2630
|
})
|
|
2499
2631
|
})
|
|
2500
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
|
+
|
|
2501
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
|
+
|
|
2502
2974
|
it('records presence', async () => {
|
|
2503
2975
|
const h = harness()
|
|
2504
2976
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's10' }))
|
|
@@ -2564,7 +3036,7 @@ describe('user-prompt-submit hook', () => {
|
|
|
2564
3036
|
expect(h.io.errLines.join('\n')).toMatch(/not paired|credential/i)
|
|
2565
3037
|
})
|
|
2566
3038
|
|
|
2567
|
-
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 () => {
|
|
2568
3040
|
const h = harness([])
|
|
2569
3041
|
const live = {
|
|
2570
3042
|
question: 'Ship it?',
|
|
@@ -2575,10 +3047,6 @@ describe('user-prompt-submit hook', () => {
|
|
|
2575
3047
|
}
|
|
2576
3048
|
writeSessionState('transition', h.env, { last_prompt_at: AWAY, pending: [live] })
|
|
2577
3049
|
|
|
2578
|
-
// A second plain `ask` resolves through the project pointer and joins the
|
|
2579
|
-
// queue behind the live question — it must not touch it. The next prompt
|
|
2580
|
-
// then resets presence before another Stop can run — the transition that
|
|
2581
|
-
// used to erase the retirement debt.
|
|
2582
3050
|
registerQuestion('transition', h.env, { question: 'Deploy it?' })
|
|
2583
3051
|
const queued = readSessionState('transition', h.env)
|
|
2584
3052
|
expect(queued.retiring ?? []).toEqual([])
|
|
@@ -2587,35 +3055,22 @@ describe('user-prompt-submit hook', () => {
|
|
|
2587
3055
|
await hookRunCommand(
|
|
2588
3056
|
h.deps,
|
|
2589
3057
|
'user-prompt-submit',
|
|
2590
|
-
stdin({
|
|
3058
|
+
stdin({
|
|
3059
|
+
session_id: 'transition',
|
|
3060
|
+
cwd: h.deps.cwd,
|
|
3061
|
+
prompt: 'keep going on the billing work',
|
|
3062
|
+
}),
|
|
2591
3063
|
'claude-code',
|
|
2592
3064
|
)
|
|
2593
3065
|
|
|
2594
|
-
|
|
2595
|
-
// one must remain queued: if Codex skipped Stop, erasing it here loses the
|
|
2596
|
-
// only copy before it ever reached a device.
|
|
2597
|
-
const retirement = h.recorder.submitted.find(
|
|
2598
|
-
(submission) => submission.draft.event === 'question_retired',
|
|
2599
|
-
)?.draft
|
|
2600
|
-
expect(retirement?.delivery.collapse_key).toBe(live.collapse_key)
|
|
2601
|
-
expect(retirement?.lifecycle).toEqual({
|
|
2602
|
-
tier: 'done',
|
|
2603
|
-
state: 'answered_elsewhere',
|
|
2604
|
-
retires_request_id: live.request_id,
|
|
2605
|
-
})
|
|
2606
|
-
expect(retirement?.targets).toEqual({
|
|
2607
|
-
mode: 'selected',
|
|
2608
|
-
device_ids: ['dev_iphone', 'dev_mac'],
|
|
2609
|
-
})
|
|
3066
|
+
expect(h.recorder.closed).toEqual([])
|
|
2610
3067
|
expect(
|
|
2611
|
-
h.recorder.submitted.filter((s) => s
|
|
2612
|
-
).toHaveLength(
|
|
3068
|
+
h.recorder.submitted.filter((s) => isRetirementSubmit(s)),
|
|
3069
|
+
).toHaveLength(0)
|
|
2613
3070
|
expect(readSessionState('transition', h.env).pending?.map((entry) => entry.question)).toEqual([
|
|
3071
|
+
'Ship it?',
|
|
2614
3072
|
'Deploy it?',
|
|
2615
3073
|
])
|
|
2616
|
-
|
|
2617
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
|
|
2618
|
-
expect(readSessionState('transition', h.env).pending).toBeUndefined()
|
|
2619
3074
|
})
|
|
2620
3075
|
|
|
2621
3076
|
it('retires a real timed-out Stop before a later prompt can accept an answer', async () => {
|
|
@@ -2625,14 +3080,14 @@ describe('user-prompt-submit hook', () => {
|
|
|
2625
3080
|
|
|
2626
3081
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
|
|
2627
3082
|
expect(readSessionState('s11', h.env).pending).toBeUndefined()
|
|
2628
|
-
|
|
2629
|
-
(entry) => entry.draft.event === 'question_retired',
|
|
2630
|
-
).length
|
|
3083
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
2631
3084
|
|
|
2632
3085
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's11', cwd: '/repo' }))
|
|
2633
3086
|
|
|
2634
3087
|
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
2635
|
-
expect(
|
|
3088
|
+
expect(
|
|
3089
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3090
|
+
).toHaveLength(0)
|
|
2636
3091
|
})
|
|
2637
3092
|
|
|
2638
3093
|
it('marks the retirement done/answered_elsewhere so it ships silently', async () => {
|
|
@@ -2651,16 +3106,16 @@ describe('user-prompt-submit hook', () => {
|
|
|
2651
3106
|
}],
|
|
2652
3107
|
})
|
|
2653
3108
|
|
|
2654
|
-
await hookRunCommand(
|
|
3109
|
+
await hookRunCommand(
|
|
3110
|
+
h.deps,
|
|
3111
|
+
'user-prompt-submit',
|
|
3112
|
+
stdin({ session_id: 's15', cwd: '/repo', prompt: 'done' }),
|
|
3113
|
+
)
|
|
2655
3114
|
|
|
2656
|
-
|
|
2657
|
-
expect(
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
// The history-entry correlation id: companions key entries by request
|
|
2661
|
-
// id and never persisted the collapse key.
|
|
2662
|
-
retires_request_id: 'req_live',
|
|
2663
|
-
})
|
|
3115
|
+
expect(h.recorder.closed).toContain('req_live')
|
|
3116
|
+
expect(
|
|
3117
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3118
|
+
).toHaveLength(0)
|
|
2664
3119
|
})
|
|
2665
3120
|
|
|
2666
3121
|
it('retires as done/answered when the answer came from a device', async () => {
|
|
@@ -2676,12 +3131,71 @@ describe('user-prompt-submit hook', () => {
|
|
|
2676
3131
|
stdin({ session_id: 's16', stop_hook_active: true }),
|
|
2677
3132
|
)
|
|
2678
3133
|
|
|
2679
|
-
|
|
2680
|
-
expect(
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
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
|
+
}],
|
|
2684
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')
|
|
2685
3199
|
})
|
|
2686
3200
|
|
|
2687
3201
|
it('publishes a session pointer so a plain `notifai ask` can find itself', async () => {
|
|
@@ -2762,6 +3276,83 @@ describe('user-prompt-submit hook', () => {
|
|
|
2762
3276
|
})
|
|
2763
3277
|
})
|
|
2764
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
|
+
|
|
2765
3356
|
describe('session-end hook', () => {
|
|
2766
3357
|
it('drops local state without touching the network and records the lifecycle', async () => {
|
|
2767
3358
|
const h = harness()
|
|
@@ -3029,7 +3620,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
3029
3620
|
'claude-code',
|
|
3030
3621
|
)
|
|
3031
3622
|
|
|
3032
|
-
const question = h.recorder.submitted.find((entry) => entry
|
|
3623
|
+
const question = h.recorder.submitted.find((entry) => isQuestionSubmit(entry))
|
|
3033
3624
|
expect(question?.draft.source).toEqual({
|
|
3034
3625
|
session_id: 'session-branch-transition',
|
|
3035
3626
|
session_label: 'Branch transition',
|
|
@@ -3051,15 +3642,10 @@ describe('telling concurrent agents apart', () => {
|
|
|
3051
3642
|
'claude-code',
|
|
3052
3643
|
)
|
|
3053
3644
|
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
session_id: 'session-branch-transition',
|
|
3059
|
-
session_label: 'Branch transition',
|
|
3060
|
-
harness: 'claude-code',
|
|
3061
|
-
branch: 'branch-a',
|
|
3062
|
-
})
|
|
3645
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
3646
|
+
expect(
|
|
3647
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3648
|
+
).toHaveLength(0)
|
|
3063
3649
|
})
|
|
3064
3650
|
|
|
3065
3651
|
it('clears registration-time branch and worktree on a detached-HEAD hook event', async () => {
|
|
@@ -3089,7 +3675,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
3089
3675
|
'claude-code',
|
|
3090
3676
|
)
|
|
3091
3677
|
|
|
3092
|
-
const question = h.recorder.submitted.find((entry) => entry
|
|
3678
|
+
const question = h.recorder.submitted.find((entry) => isQuestionSubmit(entry))
|
|
3093
3679
|
expect(question?.draft.source).toEqual({
|
|
3094
3680
|
session_id: 'session-detached',
|
|
3095
3681
|
session_label: 'Detached review',
|
|
@@ -3125,7 +3711,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
3125
3711
|
'claude-code',
|
|
3126
3712
|
)
|
|
3127
3713
|
const question = h.recorder.submitted.find(
|
|
3128
|
-
(entry) => entry
|
|
3714
|
+
(entry) => isQuestionSubmit(entry),
|
|
3129
3715
|
)
|
|
3130
3716
|
expect(question?.draft.source).toEqual({
|
|
3131
3717
|
session_id: 'session-worktree-transition',
|
|
@@ -3147,15 +3733,10 @@ describe('telling concurrent agents apart', () => {
|
|
|
3147
3733
|
}),
|
|
3148
3734
|
'claude-code',
|
|
3149
3735
|
)
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
session_id: 'session-worktree-transition',
|
|
3155
|
-
session_label: 'Worktree transition',
|
|
3156
|
-
harness: 'claude-code',
|
|
3157
|
-
branch: 'main',
|
|
3158
|
-
})
|
|
3736
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
3737
|
+
expect(
|
|
3738
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3739
|
+
).toHaveLength(0)
|
|
3159
3740
|
} finally {
|
|
3160
3741
|
runFixtureGit(h.deps.cwd, 'worktree', 'remove', '--force', linked)
|
|
3161
3742
|
}
|
|
@@ -3211,12 +3792,10 @@ describe('telling concurrent agents apart', () => {
|
|
|
3211
3792
|
stdin({ session_id: 'sess-abc', stop_hook_active: true }),
|
|
3212
3793
|
)
|
|
3213
3794
|
|
|
3214
|
-
|
|
3215
|
-
expect(
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
harness: 'claude-code',
|
|
3219
|
-
})
|
|
3795
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
3796
|
+
expect(
|
|
3797
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3798
|
+
).toHaveLength(0)
|
|
3220
3799
|
})
|
|
3221
3800
|
|
|
3222
3801
|
it('preserves a human session label without displaying the opaque id', async () => {
|
|
@@ -3394,7 +3973,7 @@ describe('durable state writes', () => {
|
|
|
3394
3973
|
expect(readFileSync(queueTarget, 'utf8')).toBe('[]\n')
|
|
3395
3974
|
})
|
|
3396
3975
|
|
|
3397
|
-
it('persists each successful retirement before a later
|
|
3976
|
+
it('persists each successful retirement before a later close failure interrupts the drain', async () => {
|
|
3398
3977
|
const h = harness()
|
|
3399
3978
|
writeSessionState('partial-drain', h.env, {
|
|
3400
3979
|
retiring: [
|
|
@@ -3408,24 +3987,30 @@ describe('durable state writes', () => {
|
|
|
3408
3987
|
{
|
|
3409
3988
|
request_id: 'req_bad',
|
|
3410
3989
|
collapse_key: 'collapse-bad',
|
|
3411
|
-
device_ids: [],
|
|
3990
|
+
device_ids: ['dev_iphone'],
|
|
3412
3991
|
question: 'Second?',
|
|
3413
3992
|
state: 'expired',
|
|
3414
3993
|
},
|
|
3415
3994
|
],
|
|
3416
3995
|
})
|
|
3996
|
+
const base = h.deps.clientFactory!('https://test.notifai.invalid', 'Bearer x')
|
|
3417
3997
|
const ctx = {
|
|
3418
|
-
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
|
+
},
|
|
3419
4005
|
config: loadConfig({ cwd: h.deps.cwd, env: h.env }),
|
|
3420
4006
|
}
|
|
3421
4007
|
|
|
3422
|
-
await
|
|
3423
|
-
/missing its device identifiers/,
|
|
3424
|
-
)
|
|
4008
|
+
await drainRetirements(ctx, 'partial-drain', h.env)
|
|
3425
4009
|
|
|
3426
4010
|
expect(readSessionState('partial-drain', h.env).retiring?.map((entry) => entry.request_id)).toEqual([
|
|
3427
4011
|
'req_bad',
|
|
3428
4012
|
])
|
|
4013
|
+
expect(h.recorder.closed).toEqual(['req_good'])
|
|
3429
4014
|
})
|
|
3430
4015
|
})
|
|
3431
4016
|
|
|
@@ -3748,14 +4333,10 @@ describe('Claude Code Stop wake route', () => {
|
|
|
3748
4333
|
expect(wake.sent).toHaveLength(1)
|
|
3749
4334
|
expect(wake.resumed).toEqual([])
|
|
3750
4335
|
expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
|
|
3751
|
-
|
|
4336
|
+
expect(h.recorder.closed).toContain('req_existing')
|
|
3752
4337
|
expect(
|
|
3753
|
-
h.recorder.submitted.
|
|
3754
|
-
|
|
3755
|
-
entry.draft.event === 'question_retired' &&
|
|
3756
|
-
entry.draft.lifecycle?.retires_request_id === 'req_existing',
|
|
3757
|
-
)?.draft.lifecycle?.state,
|
|
3758
|
-
).toBe('answered')
|
|
4338
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
4339
|
+
).toHaveLength(0)
|
|
3759
4340
|
})
|
|
3760
4341
|
|
|
3761
4342
|
it('applies the continuation cap to a woken turn that never sets stop_hook_active', async () => {
|
|
@@ -3783,7 +4364,7 @@ describe('Claude Code Stop wake route', () => {
|
|
|
3783
4364
|
'claude-code',
|
|
3784
4365
|
)
|
|
3785
4366
|
|
|
3786
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
4367
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toEqual(
|
|
3787
4368
|
[],
|
|
3788
4369
|
)
|
|
3789
4370
|
expect(readSessionState('claude-route', h.env).pending?.[0]?.question).toBe('And after that?')
|