@raidiant/notifai 8.0.0 → 9.1.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 +48 -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/command-session.d.ts +14 -0
- package/dist/command-session.js +30 -0
- package/dist/command-session.js.map +1 -0
- 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 +87 -29
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +40 -31
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-harness-context.js +21 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +20 -5
- package/dist/commands-hooks.js +509 -155
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +12 -18
- package/dist/commands-init.js +149 -47
- 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.d.ts +1 -0
- package/dist/commands-send.js +114 -17
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +12 -3
- package/dist/commands-setup-proof.js +60 -16
- 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 -2
- package/dist/commands.js +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-render.d.ts +20 -0
- package/dist/guidance-render.js +42 -0
- package/dist/guidance-render.js.map +1 -0
- 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 +83 -7
- package/dist/hooks.js +288 -90
- 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 +25 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +34 -20
- package/dist/program.js.map +1 -1
- package/dist/readiness.d.ts +6 -0
- package/dist/readiness.js +37 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send-attempts.d.ts +26 -0
- package/dist/send-attempts.js +139 -0
- package/dist/send-attempts.js.map +1 -0
- 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 +11 -0
- package/dist/session-activation.js +46 -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/command-session.ts +38 -0
- 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 +117 -41
- package/src/commands-guidance.ts +43 -32
- package/src/commands-harness-context.ts +24 -1
- package/src/commands-hooks.ts +704 -188
- package/src/commands-init.ts +180 -57
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +135 -16
- package/src/commands-setup-proof.test.ts +72 -0
- package/src/commands-setup-proof.ts +69 -16
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1281 -157
- package/src/commands.ts +2 -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-render.ts +56 -0
- package/src/guidance.test.ts +70 -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 +778 -166
- package/src/hooks.ts +368 -129
- 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 +25 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +62 -24
- package/src/readiness.ts +44 -0
- package/src/send-attempts.test.ts +143 -0
- package/src/send-attempts.ts +188 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +62 -0
- package/src/skill.test.ts +125 -11
- 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,
|
|
@@ -69,6 +70,7 @@ import {
|
|
|
69
70
|
BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
70
71
|
CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
|
|
71
72
|
} from './install-hooks.js'
|
|
73
|
+
import { GUIDANCE_CONTEXT_MAX_BYTES } from './guidance-render.js'
|
|
72
74
|
|
|
73
75
|
/** New-format test fixtures always carry the canonical body explicitly. */
|
|
74
76
|
function registerQuestion(
|
|
@@ -127,6 +129,8 @@ interface Recorder {
|
|
|
127
129
|
closed: string[]
|
|
128
130
|
/** Simulates an offline machine; retirement has to survive one. */
|
|
129
131
|
failSubmits?: boolean
|
|
132
|
+
/** Simulates an unreachable close fence so retirement stays queued. */
|
|
133
|
+
failCloses?: boolean
|
|
130
134
|
/** Runs while an agent_question submit is in flight, before onSubmitted. */
|
|
131
135
|
beforeQuestionSubmit?: () => void
|
|
132
136
|
/** Lets race tests hold every replies call until all contenders are polling. */
|
|
@@ -142,6 +146,16 @@ interface Recorder {
|
|
|
142
146
|
acknowledgementChecks?: string[]
|
|
143
147
|
}
|
|
144
148
|
|
|
149
|
+
function isQuestionSubmit(entry: { draft: { reply?: unknown } }): boolean {
|
|
150
|
+
return entry.draft.reply !== undefined
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isRetirementSubmit(entry: {
|
|
154
|
+
draft: { lifecycle?: { retires_request_id?: string } }
|
|
155
|
+
}): boolean {
|
|
156
|
+
return entry.draft.lifecycle?.retires_request_id !== undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
145
159
|
function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
146
160
|
let submissions = 0
|
|
147
161
|
const recordedReplies = (requestId: string): ReplyView[] => {
|
|
@@ -215,7 +229,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
215
229
|
health: async () => true,
|
|
216
230
|
submit: async (body) => {
|
|
217
231
|
if (recorder.failSubmits === true) throw new Error('offline')
|
|
218
|
-
if (body.draft.
|
|
232
|
+
if (body.draft.reply !== undefined) recorder.beforeQuestionSubmit?.()
|
|
219
233
|
recorder.submitted.push(body)
|
|
220
234
|
submissions += 1
|
|
221
235
|
const requestId = body.request_id ?? `req_hook_${submissions}`
|
|
@@ -250,6 +264,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
250
264
|
}) satisfies ListRepliesResponse
|
|
251
265
|
},
|
|
252
266
|
closeReplies: async (requestId) => {
|
|
267
|
+
if (recorder.failCloses === true) throw new Error('offline')
|
|
253
268
|
recorder.closed.push(requestId)
|
|
254
269
|
return {
|
|
255
270
|
request_id: requestId,
|
|
@@ -736,7 +751,7 @@ describe('what the hook leaves behind', () => {
|
|
|
736
751
|
)
|
|
737
752
|
|
|
738
753
|
const raw = readFileSync(activeLogPath(h.env), 'utf8')
|
|
739
|
-
expect(raw.
|
|
754
|
+
expect(raw).not.toContain('Allow exactly once')
|
|
740
755
|
const answerRecords = readLogRecords(h.env, { event: ['hook.answer'] }).records
|
|
741
756
|
expect(answerRecords.map((record) => record.data?.['stage'])).toEqual([
|
|
742
757
|
'queued',
|
|
@@ -867,7 +882,7 @@ describe('terminal-first grace window', () => {
|
|
|
867
882
|
|
|
868
883
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present1' }))
|
|
869
884
|
|
|
870
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
885
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
|
|
871
886
|
expect(submittedAt).toBe(NOW)
|
|
872
887
|
})
|
|
873
888
|
|
|
@@ -900,7 +915,7 @@ describe('terminal-first grace window', () => {
|
|
|
900
915
|
|
|
901
916
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
|
|
902
917
|
|
|
903
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
918
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
|
|
904
919
|
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
|
|
905
920
|
})
|
|
906
921
|
|
|
@@ -929,7 +944,7 @@ describe('terminal-first grace window', () => {
|
|
|
929
944
|
expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 360_000)
|
|
930
945
|
expect(h.deps.now?.()).toBeLessThan(NOW + 361_000)
|
|
931
946
|
expect(
|
|
932
|
-
h.recorder.submitted.find((entry) => entry
|
|
947
|
+
h.recorder.submitted.find((entry) => isQuestionSubmit(entry))?.draft.reply
|
|
933
948
|
?.expires_in_seconds,
|
|
934
949
|
).toBeGreaterThanOrEqual(60)
|
|
935
950
|
})
|
|
@@ -1038,7 +1053,7 @@ describe('nagging guards', () => {
|
|
|
1038
1053
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
|
|
1039
1054
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
|
|
1040
1055
|
|
|
1041
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
1056
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
|
|
1042
1057
|
})
|
|
1043
1058
|
|
|
1044
1059
|
it('respects the harness recursion guard', async () => {
|
|
@@ -1066,7 +1081,7 @@ describe('nagging guards', () => {
|
|
|
1066
1081
|
|
|
1067
1082
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3', stop_hook_active: true }))
|
|
1068
1083
|
|
|
1069
|
-
const questions = h.recorder.submitted.filter((entry) => entry
|
|
1084
|
+
const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
|
|
1070
1085
|
expect(questions.map((entry) => entry.draft.presentation.body)).toEqual([
|
|
1071
1086
|
'First question?',
|
|
1072
1087
|
'Follow-up question?',
|
|
@@ -1123,7 +1138,7 @@ describe('late answer collection', () => {
|
|
|
1123
1138
|
|
|
1124
1139
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
|
|
1125
1140
|
|
|
1126
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
1141
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
|
|
1127
1142
|
expect(h.recorder.closed).toContain('req_live')
|
|
1128
1143
|
expect(readSessionState('late-stop', h.env).pending).toBeUndefined()
|
|
1129
1144
|
expect(h.io.outLines).toHaveLength(1)
|
|
@@ -1170,14 +1185,10 @@ describe('late answer collection', () => {
|
|
|
1170
1185
|
stdin({ session_id: 'late-prompt', stop_hook_active: true }),
|
|
1171
1186
|
)
|
|
1172
1187
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
state: 'answered',
|
|
1178
|
-
retires_request_id: 'req_live',
|
|
1179
|
-
})
|
|
1180
|
-
expect(retirement?.lifecycle?.state).not.toBe('answered_elsewhere')
|
|
1188
|
+
expect(h.recorder.closed).toContain('req_live')
|
|
1189
|
+
expect(
|
|
1190
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
1191
|
+
).toHaveLength(0)
|
|
1181
1192
|
expect(readSessionState('late-prompt', h.env).pending).toBeUndefined()
|
|
1182
1193
|
expect(readSessionState('late-prompt', h.env).accepted).toBeUndefined()
|
|
1183
1194
|
})
|
|
@@ -1191,10 +1202,10 @@ describe('OpenCode answer continuation', () => {
|
|
|
1191
1202
|
|
|
1192
1203
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'open1' }), 'opencode')
|
|
1193
1204
|
|
|
1194
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
1205
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
|
|
1195
1206
|
expect(h.recorder.closed).toEqual([])
|
|
1196
1207
|
expect(
|
|
1197
|
-
h.recorder.submitted.filter((entry) => entry
|
|
1208
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
1198
1209
|
).toHaveLength(0)
|
|
1199
1210
|
expect(readSessionState('open1', h.env).pending).toBeUndefined()
|
|
1200
1211
|
expect(h.io.outLines).toEqual([])
|
|
@@ -1268,7 +1279,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1268
1279
|
return {
|
|
1269
1280
|
...client,
|
|
1270
1281
|
submit: async (body: SubmitNotificationRequestT) => {
|
|
1271
|
-
if (body.draft.
|
|
1282
|
+
if (body.draft.reply === undefined) return client.submit(body, 0)
|
|
1272
1283
|
acceptedRequestId = body.request_id
|
|
1273
1284
|
h.recorder.submitted.push(body)
|
|
1274
1285
|
h.recorder.receipts.push(body.request_id!)
|
|
@@ -1496,7 +1507,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1496
1507
|
|
|
1497
1508
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline' }))
|
|
1498
1509
|
|
|
1499
|
-
expect(h.deps.now?.()).toBe(NOW +
|
|
1510
|
+
expect(h.deps.now?.()).toBe(NOW + 57_000)
|
|
1500
1511
|
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1501
1512
|
expect(readSessionState('deadline', h.env).pending).toBeUndefined()
|
|
1502
1513
|
expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
|
|
@@ -1512,7 +1523,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1512
1523
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latency' }))
|
|
1513
1524
|
|
|
1514
1525
|
const windows = h.recorder.submitted
|
|
1515
|
-
.filter((entry) => entry
|
|
1526
|
+
.filter((entry) => isQuestionSubmit(entry))
|
|
1516
1527
|
.map((entry) => entry.draft.reply?.expires_in_seconds)
|
|
1517
1528
|
// How long the user may answer does not shrink because this owner spent
|
|
1518
1529
|
// part of its own budget getting the question out. Both questions stay
|
|
@@ -1552,7 +1563,116 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1552
1563
|
|
|
1553
1564
|
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Second answer')
|
|
1554
1565
|
expect(readSessionState('successor', h.env).pending).toBeUndefined()
|
|
1555
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
1566
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(2)
|
|
1567
|
+
})
|
|
1568
|
+
|
|
1569
|
+
it('does not start another waiter for a live question whose owner lease is already spent', async () => {
|
|
1570
|
+
const h = harness([])
|
|
1571
|
+
h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
|
|
1572
|
+
writeSessionState('spent-owner', h.env, {
|
|
1573
|
+
last_prompt_at: AWAY,
|
|
1574
|
+
pending: [
|
|
1575
|
+
{
|
|
1576
|
+
question: 'Old question?',
|
|
1577
|
+
asked_at: NOW - 3_300_000,
|
|
1578
|
+
request_id: 'req_old',
|
|
1579
|
+
collapse_key: 'question-old',
|
|
1580
|
+
device_ids: ['dev_iphone'],
|
|
1581
|
+
reply_deadline_at: NOW + 86_400_000,
|
|
1582
|
+
owner_deadline_at: NOW - 1,
|
|
1583
|
+
},
|
|
1584
|
+
],
|
|
1585
|
+
})
|
|
1586
|
+
|
|
1587
|
+
const started = h.deps.now?.() ?? NOW
|
|
1588
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'spent-owner' }))
|
|
1589
|
+
|
|
1590
|
+
expect(h.deps.now?.()).toBeLessThan(started + 30_000)
|
|
1591
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
|
|
1592
|
+
expect(readSessionState('spent-owner', h.env).pending?.[0]?.request_id).toBe('req_old')
|
|
1593
|
+
expect(h.io.errLines.join('\n')).toContain('previous waiter already used its ceiling')
|
|
1594
|
+
})
|
|
1595
|
+
|
|
1596
|
+
it('does not re-arm a live question written before owner leases were persisted', async () => {
|
|
1597
|
+
const h = harness([])
|
|
1598
|
+
h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
|
|
1599
|
+
writeSessionState('legacy-spent', h.env, {
|
|
1600
|
+
last_prompt_at: AWAY,
|
|
1601
|
+
pending: [
|
|
1602
|
+
{
|
|
1603
|
+
question: 'Degraded waiter leftover?',
|
|
1604
|
+
asked_at: NOW - 3_300_000,
|
|
1605
|
+
request_id: 'req_ATU4_legacy',
|
|
1606
|
+
collapse_key: 'question-legacy',
|
|
1607
|
+
device_ids: ['dev_iphone'],
|
|
1608
|
+
reply_deadline_at: NOW + 86_400_000,
|
|
1609
|
+
},
|
|
1610
|
+
],
|
|
1611
|
+
})
|
|
1612
|
+
|
|
1613
|
+
const started = h.deps.now?.() ?? NOW
|
|
1614
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'legacy-spent' }))
|
|
1615
|
+
|
|
1616
|
+
expect(h.deps.now?.()).toBeLessThan(started + 30_000)
|
|
1617
|
+
expect(readSessionState('legacy-spent', h.env).pending?.[0]?.request_id).toBe(
|
|
1618
|
+
'req_ATU4_legacy',
|
|
1619
|
+
)
|
|
1620
|
+
})
|
|
1621
|
+
|
|
1622
|
+
it('pushes a newly registered question on the next Stop after a spent waiter', async () => {
|
|
1623
|
+
const h = harness([])
|
|
1624
|
+
h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
|
|
1625
|
+
writeSessionState('after-degraded', h.env, {
|
|
1626
|
+
last_prompt_at: AWAY,
|
|
1627
|
+
pending: [
|
|
1628
|
+
{
|
|
1629
|
+
question: 'Earlier question?',
|
|
1630
|
+
asked_at: NOW - 3_300_000,
|
|
1631
|
+
request_id: 'req_earlier',
|
|
1632
|
+
collapse_key: 'question-earlier',
|
|
1633
|
+
device_ids: ['dev_iphone'],
|
|
1634
|
+
reply_deadline_at: NOW + 86_400_000,
|
|
1635
|
+
},
|
|
1636
|
+
],
|
|
1637
|
+
})
|
|
1638
|
+
|
|
1639
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'after-degraded' }))
|
|
1640
|
+
registerQuestion('after-degraded', h.env, { question: 'New question?' }, h.deps.now?.())
|
|
1641
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'after-degraded' }))
|
|
1642
|
+
|
|
1643
|
+
const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
|
|
1644
|
+
expect(questions.map((entry) => entry.draft.presentation.body)).toEqual(['New question?'])
|
|
1645
|
+
expect(readSessionState('after-degraded', h.env).pending?.map((entry) => entry.question)).toEqual(
|
|
1646
|
+
['Earlier question?', 'New question?'],
|
|
1647
|
+
)
|
|
1648
|
+
})
|
|
1649
|
+
|
|
1650
|
+
it('still waits out a live owner lease for an independent answer', async () => {
|
|
1651
|
+
const h = harness([])
|
|
1652
|
+
const answerAt = NOW + 10_000
|
|
1653
|
+
h.recorder.repliesFor = (requestId) =>
|
|
1654
|
+
requestId === 'req_live' && (h.deps.now?.() ?? 0) >= answerAt
|
|
1655
|
+
? [reply({ text: 'Later' })]
|
|
1656
|
+
: []
|
|
1657
|
+
writeSessionState('lease', h.env, {
|
|
1658
|
+
last_prompt_at: AWAY,
|
|
1659
|
+
pending: [
|
|
1660
|
+
{
|
|
1661
|
+
question: 'Still waiting?',
|
|
1662
|
+
asked_at: NOW,
|
|
1663
|
+
request_id: 'req_live',
|
|
1664
|
+
collapse_key: 'question-live',
|
|
1665
|
+
device_ids: ['dev_iphone'],
|
|
1666
|
+
reply_deadline_at: NOW + 86_400_000,
|
|
1667
|
+
owner_deadline_at: NOW + 60_000,
|
|
1668
|
+
},
|
|
1669
|
+
],
|
|
1670
|
+
})
|
|
1671
|
+
|
|
1672
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'lease' }))
|
|
1673
|
+
|
|
1674
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Later')
|
|
1675
|
+
expect(h.deps.now?.()).toBeGreaterThanOrEqual(answerAt)
|
|
1556
1676
|
})
|
|
1557
1677
|
})
|
|
1558
1678
|
|
|
@@ -1588,14 +1708,9 @@ describe('Cursor stop output', () => {
|
|
|
1588
1708
|
* questions piling up on a phone teach the user to ignore the surface.
|
|
1589
1709
|
*/
|
|
1590
1710
|
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
|
-
}))
|
|
1711
|
+
/** Request ids whose close fence proved retirement. */
|
|
1712
|
+
function retirements(h: Harness): string[] {
|
|
1713
|
+
return h.recorder.closed
|
|
1599
1714
|
}
|
|
1600
1715
|
|
|
1601
1716
|
function seedLive(h: Harness, sessionId: string, requestId = 'req_live'): void {
|
|
@@ -1635,13 +1750,17 @@ describe('question queueing and retirement debt', () => {
|
|
|
1635
1750
|
|
|
1636
1751
|
// Offline when the user returns: the wipe parks the retirement, the
|
|
1637
1752
|
// drain fails, and it must not forget what it was for.
|
|
1638
|
-
h.recorder.
|
|
1639
|
-
await hookRunCommand(
|
|
1753
|
+
h.recorder.failCloses = true
|
|
1754
|
+
await hookRunCommand(
|
|
1755
|
+
h.deps,
|
|
1756
|
+
'user-prompt-submit',
|
|
1757
|
+
stdin({ session_id: 'sup2', prompt: 'done' }),
|
|
1758
|
+
)
|
|
1640
1759
|
expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
|
|
1641
1760
|
|
|
1642
|
-
h.recorder.
|
|
1761
|
+
h.recorder.failCloses = false
|
|
1643
1762
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
|
|
1644
|
-
expect(retirements(h)).
|
|
1763
|
+
expect(retirements(h)).toContain(first)
|
|
1645
1764
|
expect(readSessionState('sup2', h.env).retiring).toEqual([])
|
|
1646
1765
|
})
|
|
1647
1766
|
|
|
@@ -1661,16 +1780,22 @@ describe('question queueing and retirement debt', () => {
|
|
|
1661
1780
|
const configDir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
1662
1781
|
mkdirSync(configDir, { recursive: true })
|
|
1663
1782
|
writeFileSync(path.join(configDir, 'config.toml'), 'devices = ["dev_iphone"]\n')
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1783
|
+
h.recorder.failCloses = true
|
|
1784
|
+
await hookRunCommand(
|
|
1785
|
+
h.deps,
|
|
1786
|
+
'user-prompt-submit',
|
|
1787
|
+
stdin({ session_id: 'sup-targets', prompt: 'done' }),
|
|
1668
1788
|
)
|
|
1669
|
-
|
|
1670
|
-
expect(
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1789
|
+
|
|
1790
|
+
expect(readSessionState('sup-targets', h.env).retiring).toEqual([
|
|
1791
|
+
expect.objectContaining({
|
|
1792
|
+
request_id: 'req_live',
|
|
1793
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
1794
|
+
}),
|
|
1795
|
+
])
|
|
1796
|
+
expect(
|
|
1797
|
+
h.recorder.submitted.filter((submission) => isRetirementSubmit(submission)),
|
|
1798
|
+
).toHaveLength(0)
|
|
1674
1799
|
})
|
|
1675
1800
|
|
|
1676
1801
|
it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
|
|
@@ -1681,14 +1806,18 @@ describe('question queueing and retirement debt', () => {
|
|
|
1681
1806
|
seedLive(h, 'sup3')
|
|
1682
1807
|
const first = 'req_live'
|
|
1683
1808
|
|
|
1684
|
-
h.recorder.
|
|
1685
|
-
await hookRunCommand(
|
|
1809
|
+
h.recorder.failCloses = true
|
|
1810
|
+
await hookRunCommand(
|
|
1811
|
+
h.deps,
|
|
1812
|
+
'user-prompt-submit',
|
|
1813
|
+
stdin({ session_id: 'sup3', prompt: 'done' }),
|
|
1814
|
+
)
|
|
1686
1815
|
expect(readSessionState('sup3', h.env).retiring).toHaveLength(1)
|
|
1687
|
-
h.recorder.
|
|
1816
|
+
h.recorder.failCloses = false
|
|
1688
1817
|
|
|
1689
1818
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3', stop_hook_active: true }))
|
|
1690
1819
|
|
|
1691
|
-
expect(retirements(h)).
|
|
1820
|
+
expect(retirements(h)).toContain(first)
|
|
1692
1821
|
expect(readSessionState('sup3', h.env).retiring).toEqual([])
|
|
1693
1822
|
})
|
|
1694
1823
|
|
|
@@ -1701,13 +1830,21 @@ describe('question queueing and retirement debt', () => {
|
|
|
1701
1830
|
|
|
1702
1831
|
// The first return is offline: the wipe parks, the drain fails, and the
|
|
1703
1832
|
// next prompt must still find the debt.
|
|
1704
|
-
h.recorder.
|
|
1705
|
-
await hookRunCommand(
|
|
1706
|
-
|
|
1833
|
+
h.recorder.failCloses = true
|
|
1834
|
+
await hookRunCommand(
|
|
1835
|
+
h.deps,
|
|
1836
|
+
'user-prompt-submit',
|
|
1837
|
+
stdin({ session_id: 'sup4', prompt: 'done' }),
|
|
1838
|
+
)
|
|
1839
|
+
h.recorder.failCloses = false
|
|
1707
1840
|
|
|
1708
|
-
await hookRunCommand(
|
|
1841
|
+
await hookRunCommand(
|
|
1842
|
+
h.deps,
|
|
1843
|
+
'user-prompt-submit',
|
|
1844
|
+
stdin({ session_id: 'sup4', prompt: 'ok' }),
|
|
1845
|
+
)
|
|
1709
1846
|
|
|
1710
|
-
expect(retirements(h)).
|
|
1847
|
+
expect(retirements(h)).toContain(first)
|
|
1711
1848
|
expect(readSessionState('sup4', h.env).retiring).toEqual([])
|
|
1712
1849
|
})
|
|
1713
1850
|
|
|
@@ -1741,7 +1878,7 @@ describe('several questions in flight', () => {
|
|
|
1741
1878
|
|
|
1742
1879
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi1' }))
|
|
1743
1880
|
|
|
1744
|
-
const questions = h.recorder.submitted.filter((s) => s
|
|
1881
|
+
const questions = h.recorder.submitted.filter((s) => isQuestionSubmit(s))
|
|
1745
1882
|
expect(questions.map((s) => s.draft.presentation.body)).toEqual(['Ship it?', 'Deploy where?'])
|
|
1746
1883
|
// Each is its own notification with its own collapse key — one ask never
|
|
1747
1884
|
// stands in for, or replaces, another.
|
|
@@ -1785,7 +1922,7 @@ describe('several questions in flight', () => {
|
|
|
1785
1922
|
'codex',
|
|
1786
1923
|
)
|
|
1787
1924
|
|
|
1788
|
-
expect(h.recorder.submitted.filter((s) => s
|
|
1925
|
+
expect(h.recorder.submitted.filter((s) => isQuestionSubmit(s))).toHaveLength(1)
|
|
1789
1926
|
expect(readSessionState('codex-form', h.env).pending).toBeUndefined()
|
|
1790
1927
|
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1791
1928
|
expect(h.io.errLines.join('\n')).toMatch(/expired with its continuation owner/)
|
|
@@ -1819,7 +1956,7 @@ describe('several questions in flight', () => {
|
|
|
1819
1956
|
|
|
1820
1957
|
// Submit recorded the durable id before any wait; a 500 must not erase it
|
|
1821
1958
|
// mid-flight, and recovery must still surface the late answer.
|
|
1822
|
-
const questions = h.recorder.submitted.filter((s) => s
|
|
1959
|
+
const questions = h.recorder.submitted.filter((s) => isQuestionSubmit(s))
|
|
1823
1960
|
expect(questions).toHaveLength(1)
|
|
1824
1961
|
expect(h.recorder.receipts[0]).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
|
|
1825
1962
|
expect(polls).toBeGreaterThanOrEqual(3)
|
|
@@ -1959,13 +2096,10 @@ describe('several questions in flight', () => {
|
|
|
1959
2096
|
)
|
|
1960
2097
|
expect(h.io.outLines).toEqual([])
|
|
1961
2098
|
expect(readSessionState('answer-handoff', h.env).accepted).toBeUndefined()
|
|
2099
|
+
expect(h.recorder.closed).toContain('req_existing')
|
|
1962
2100
|
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')
|
|
2101
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
2102
|
+
).toHaveLength(0)
|
|
1969
2103
|
})
|
|
1970
2104
|
|
|
1971
2105
|
it('blocks Stop with the exact command until a required Agent Acknowledgement is recorded', async () => {
|
|
@@ -2155,19 +2289,14 @@ describe('several questions in flight', () => {
|
|
|
2155
2289
|
expect(followup.reason).toContain('Staging')
|
|
2156
2290
|
expect(readSessionState('multi3', h.env).pending).toBeUndefined()
|
|
2157
2291
|
expect(
|
|
2158
|
-
h.recorder.submitted.filter((s) => s
|
|
2292
|
+
h.recorder.submitted.filter((s) => isQuestionSubmit(s)),
|
|
2159
2293
|
).toHaveLength(2)
|
|
2160
2294
|
})
|
|
2161
2295
|
})
|
|
2162
2296
|
|
|
2163
2297
|
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
|
-
}))
|
|
2298
|
+
function retirements(h: Harness): string[] {
|
|
2299
|
+
return h.recorder.closed
|
|
2171
2300
|
}
|
|
2172
2301
|
|
|
2173
2302
|
/** State a still-owned request can have when SessionEnd races its Stop. */
|
|
@@ -2198,7 +2327,7 @@ describe('a question that outlives its session', () => {
|
|
|
2198
2327
|
// A different session's next hook holds a client and inherits the debt.
|
|
2199
2328
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'next1' }))
|
|
2200
2329
|
expect(h.recorder.closed).toContain(first)
|
|
2201
|
-
expect(retirements(h)).
|
|
2330
|
+
expect(retirements(h)).toContain(first)
|
|
2202
2331
|
})
|
|
2203
2332
|
|
|
2204
2333
|
it('carries parked retirements across SessionEnd too', async () => {
|
|
@@ -2207,13 +2336,17 @@ describe('a question that outlives its session', () => {
|
|
|
2207
2336
|
|
|
2208
2337
|
// Died while offline: the user came back, the wipe parked the
|
|
2209
2338
|
// retirement, and the drain could not send it.
|
|
2210
|
-
h.recorder.
|
|
2211
|
-
await hookRunCommand(
|
|
2212
|
-
|
|
2339
|
+
h.recorder.failCloses = true
|
|
2340
|
+
await hookRunCommand(
|
|
2341
|
+
h.deps,
|
|
2342
|
+
'user-prompt-submit',
|
|
2343
|
+
stdin({ session_id: 'dead2', prompt: 'done' }),
|
|
2344
|
+
)
|
|
2345
|
+
h.recorder.failCloses = false
|
|
2213
2346
|
|
|
2214
2347
|
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead2' }))
|
|
2215
2348
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next2' }))
|
|
2216
|
-
expect(retirements(h)).
|
|
2349
|
+
expect(retirements(h)).toContain(first)
|
|
2217
2350
|
})
|
|
2218
2351
|
|
|
2219
2352
|
it('keeps the debt when the drain fails, and drops entries past the TTL', async () => {
|
|
@@ -2222,16 +2355,16 @@ describe('a question that outlives its session', () => {
|
|
|
2222
2355
|
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead3' }))
|
|
2223
2356
|
|
|
2224
2357
|
// Still offline: the queue must survive a failed drain.
|
|
2225
|
-
h.recorder.
|
|
2358
|
+
h.recorder.failCloses = true
|
|
2226
2359
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
|
|
2227
|
-
h.recorder.
|
|
2360
|
+
h.recorder.failCloses = false
|
|
2228
2361
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
|
|
2229
|
-
expect(retirements(h)).
|
|
2362
|
+
expect(retirements(h)).toContain(first)
|
|
2230
2363
|
|
|
2231
2364
|
// And a second drain does not send it twice.
|
|
2232
|
-
const count = retirements(h).filter((
|
|
2365
|
+
const count = retirements(h).filter((id) => id === first).length
|
|
2233
2366
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
|
|
2234
|
-
expect(retirements(h).filter((
|
|
2367
|
+
expect(retirements(h).filter((id) => id === first)).toHaveLength(count)
|
|
2235
2368
|
})
|
|
2236
2369
|
|
|
2237
2370
|
it('gives up on an orphan that outlived the longest answerable window', async () => {
|
|
@@ -2498,7 +2631,373 @@ describe('ask registration', () => {
|
|
|
2498
2631
|
})
|
|
2499
2632
|
})
|
|
2500
2633
|
|
|
2634
|
+
describe('session-start hook', () => {
|
|
2635
|
+
it('adds proactive Notifai context before setup or machine authentication exists', async () => {
|
|
2636
|
+
const h = harness()
|
|
2637
|
+
h.deps.store.load = () => null
|
|
2638
|
+
|
|
2639
|
+
for (const hookHarness of ['claude-code', 'codex'] as const) {
|
|
2640
|
+
h.io.outLines = []
|
|
2641
|
+
await hookRunCommand(
|
|
2642
|
+
h.deps,
|
|
2643
|
+
'session-start',
|
|
2644
|
+
stdin({ session_id: `proactive-guidance-${hookHarness}`, source: 'startup' }),
|
|
2645
|
+
hookHarness,
|
|
2646
|
+
)
|
|
2647
|
+
|
|
2648
|
+
const first = JSON.parse(h.io.outLines.at(-1)!) as {
|
|
2649
|
+
hookSpecificOutput?: { hookEventName?: string; additionalContext?: string }
|
|
2650
|
+
}
|
|
2651
|
+
expect(first.hookSpecificOutput).toMatchObject({
|
|
2652
|
+
hookEventName: 'SessionStart',
|
|
2653
|
+
})
|
|
2654
|
+
expect(first.hookSpecificOutput?.additionalContext).toMatch(/Notifai.*session/i)
|
|
2655
|
+
expect(first.hookSpecificOutput?.additionalContext).toContain('# How to read this guidance')
|
|
2656
|
+
expect(first.hookSpecificOutput?.additionalContext).toContain(
|
|
2657
|
+
'<!-- notifai:guidance topic=when-to-notify from=shipped default -->',
|
|
2658
|
+
)
|
|
2659
|
+
expect(first.hookSpecificOutput?.additionalContext).not.toContain('run `notifai guidance` once')
|
|
2660
|
+
expect(h.io.errLines.join('\n')).not.toMatch(/not paired/i)
|
|
2661
|
+
}
|
|
2662
|
+
})
|
|
2663
|
+
|
|
2664
|
+
it('caps the total root lifecycle context and falls back without partial guidance', async () => {
|
|
2665
|
+
const h = harness()
|
|
2666
|
+
const guidance = path.join(h.deps.cwd, '.notifai', 'guidance')
|
|
2667
|
+
mkdirSync(guidance, { recursive: true })
|
|
2668
|
+
writeFileSync(path.join(guidance, 'titles.md'), `private-start-${'x'.repeat(16_000)}`)
|
|
2669
|
+
writeFileSync(path.join(guidance, 'bodies.md'), `private-end-${'y'.repeat(16_000)}`)
|
|
2670
|
+
|
|
2671
|
+
await hookRunCommand(
|
|
2672
|
+
h.deps,
|
|
2673
|
+
'session-start',
|
|
2674
|
+
stdin({ session_id: 'bounded-root-guidance', source: 'startup' }),
|
|
2675
|
+
'claude-code',
|
|
2676
|
+
)
|
|
2677
|
+
const output = JSON.parse(h.io.outLines.at(-1) ?? '{}') as {
|
|
2678
|
+
hookSpecificOutput?: { additionalContext?: string }
|
|
2679
|
+
}
|
|
2680
|
+
const context = output.hookSpecificOutput?.additionalContext ?? ''
|
|
2681
|
+
expect(Buffer.byteLength(context, 'utf8')).toBeLessThanOrEqual(GUIDANCE_CONTEXT_MAX_BYTES)
|
|
2682
|
+
expect(context).toContain('above the')
|
|
2683
|
+
expect(context).toContain('run `notifai guidance` once')
|
|
2684
|
+
expect(context).not.toContain('private-start-')
|
|
2685
|
+
expect(context).not.toContain('private-end-')
|
|
2686
|
+
})
|
|
2687
|
+
|
|
2688
|
+
it('uses each supported lifecycle output contract and gives workers deterministic ownership', async () => {
|
|
2689
|
+
const h = harness()
|
|
2690
|
+
h.deps.store.load = () => null
|
|
2691
|
+
|
|
2692
|
+
await hookRunCommand(
|
|
2693
|
+
h.deps,
|
|
2694
|
+
'session-start',
|
|
2695
|
+
stdin({ conversation_id: 'cursor-session', workspace_roots: [h.deps.cwd] }),
|
|
2696
|
+
'cursor',
|
|
2697
|
+
)
|
|
2698
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
|
|
2699
|
+
additional_context: expect.stringMatching(/Notifai.*session/i),
|
|
2700
|
+
})
|
|
2701
|
+
|
|
2702
|
+
h.io.outLines = []
|
|
2703
|
+
await hookRunCommand(
|
|
2704
|
+
h.deps,
|
|
2705
|
+
'subagent-start',
|
|
2706
|
+
stdin({ session_id: 'subagent-session' }),
|
|
2707
|
+
'claude-code',
|
|
2708
|
+
)
|
|
2709
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
|
|
2710
|
+
hookSpecificOutput: {
|
|
2711
|
+
hookEventName: 'SubagentStart',
|
|
2712
|
+
additionalContext: expect.stringMatching(
|
|
2713
|
+
/report Agent Events to the parent.*do not send.*unless the parent explicitly delegated/i,
|
|
2714
|
+
),
|
|
2715
|
+
},
|
|
2716
|
+
})
|
|
2717
|
+
expect(h.io.outLines.at(-1)).not.toContain('topic=titles')
|
|
2718
|
+
expect(h.io.outLines.at(-1)).not.toContain('topic=bodies')
|
|
2719
|
+
})
|
|
2720
|
+
|
|
2721
|
+
it('uses Cursor native Stop follow-up once when its SessionStart context is lossy', async () => {
|
|
2722
|
+
const h = harness()
|
|
2723
|
+
h.deps.store.load = () => null
|
|
2724
|
+
const input = stdin({
|
|
2725
|
+
conversation_id: 'cursor-activation',
|
|
2726
|
+
workspace_roots: [h.deps.cwd],
|
|
2727
|
+
loop_count: 0,
|
|
2728
|
+
})
|
|
2729
|
+
|
|
2730
|
+
await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
|
|
2731
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
|
|
2732
|
+
followup_message: expect.stringMatching(/effective, provenance-marked guidance.*when-to-notify/is),
|
|
2733
|
+
})
|
|
2734
|
+
|
|
2735
|
+
h.io.outLines = []
|
|
2736
|
+
await hookRunCommand(
|
|
2737
|
+
h.deps,
|
|
2738
|
+
'activation-stop',
|
|
2739
|
+
stdin({
|
|
2740
|
+
conversation_id: 'cursor-activation',
|
|
2741
|
+
workspace_roots: [h.deps.cwd],
|
|
2742
|
+
loop_count: 1,
|
|
2743
|
+
}),
|
|
2744
|
+
'cursor',
|
|
2745
|
+
)
|
|
2746
|
+
expect(h.io.outLines).toEqual([])
|
|
2747
|
+
expect(readSessionState('cursor-activation', h.env).cursor_activation_confirmed_at).toBe(NOW)
|
|
2748
|
+
|
|
2749
|
+
await hookRunCommand(
|
|
2750
|
+
h.deps,
|
|
2751
|
+
'session-start',
|
|
2752
|
+
stdin({ conversation_id: 'cursor-activation', workspace_roots: [h.deps.cwd] }),
|
|
2753
|
+
'cursor',
|
|
2754
|
+
)
|
|
2755
|
+
h.io.outLines = []
|
|
2756
|
+
await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
|
|
2757
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
|
|
2758
|
+
})
|
|
2759
|
+
|
|
2760
|
+
it('does not let Cursor activation override cancellation or a live question continuation', async () => {
|
|
2761
|
+
const h = harness()
|
|
2762
|
+
const base = {
|
|
2763
|
+
conversation_id: 'cursor-coexistence',
|
|
2764
|
+
workspace_roots: [h.deps.cwd],
|
|
2765
|
+
loop_count: 0,
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
await hookRunCommand(
|
|
2769
|
+
h.deps,
|
|
2770
|
+
'activation-stop',
|
|
2771
|
+
stdin({ ...base, status: 'aborted' }),
|
|
2772
|
+
'cursor',
|
|
2773
|
+
)
|
|
2774
|
+
expect(h.io.outLines).toEqual([])
|
|
2775
|
+
|
|
2776
|
+
registerQuestion('cursor-coexistence', h.env, { question: 'Choose a path?' })
|
|
2777
|
+
await hookRunCommand(
|
|
2778
|
+
h.deps,
|
|
2779
|
+
'activation-stop',
|
|
2780
|
+
stdin({ ...base, status: 'completed' }),
|
|
2781
|
+
'cursor',
|
|
2782
|
+
)
|
|
2783
|
+
expect(h.io.outLines).toEqual([])
|
|
2784
|
+
expect(readSessionState('cursor-coexistence', h.env).cursor_activation_claimed_at).toBeUndefined()
|
|
2785
|
+
|
|
2786
|
+
await hookRunCommand(
|
|
2787
|
+
h.deps,
|
|
2788
|
+
'activation-stop',
|
|
2789
|
+
stdin({ ...base, status: 'completed', loop_count: 1 }),
|
|
2790
|
+
'cursor',
|
|
2791
|
+
)
|
|
2792
|
+
expect(h.io.outLines).toEqual([])
|
|
2793
|
+
expect(readSessionState('cursor-coexistence', h.env).cursor_activation_confirmed_at).toBeUndefined()
|
|
2794
|
+
})
|
|
2795
|
+
|
|
2796
|
+
it('retries an unconfirmed Cursor activation claim after the crash guard expires', async () => {
|
|
2797
|
+
const h = harness()
|
|
2798
|
+
const input = stdin({
|
|
2799
|
+
conversation_id: 'cursor-activation-retry',
|
|
2800
|
+
workspace_roots: [h.deps.cwd],
|
|
2801
|
+
status: 'completed',
|
|
2802
|
+
loop_count: 0,
|
|
2803
|
+
})
|
|
2804
|
+
|
|
2805
|
+
await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
|
|
2806
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
|
|
2807
|
+
|
|
2808
|
+
h.io.outLines = []
|
|
2809
|
+
await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
|
|
2810
|
+
expect(h.io.outLines).toEqual([])
|
|
2811
|
+
|
|
2812
|
+
h.advanceClock(30_001)
|
|
2813
|
+
await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
|
|
2814
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
|
|
2815
|
+
})
|
|
2816
|
+
|
|
2817
|
+
it('fails closed without a Cursor conversation id and still activates after an errored turn', async () => {
|
|
2818
|
+
const h = harness()
|
|
2819
|
+
|
|
2820
|
+
await hookRunCommand(
|
|
2821
|
+
h.deps,
|
|
2822
|
+
'activation-stop',
|
|
2823
|
+
stdin({ workspace_roots: [h.deps.cwd], status: 'completed', loop_count: 0 }),
|
|
2824
|
+
'cursor',
|
|
2825
|
+
)
|
|
2826
|
+
expect(h.io.outLines).toEqual([])
|
|
2827
|
+
|
|
2828
|
+
await hookRunCommand(
|
|
2829
|
+
h.deps,
|
|
2830
|
+
'activation-stop',
|
|
2831
|
+
stdin({
|
|
2832
|
+
conversation_id: 'cursor-error-activation',
|
|
2833
|
+
workspace_roots: [h.deps.cwd],
|
|
2834
|
+
status: 'error',
|
|
2835
|
+
loop_count: 0,
|
|
2836
|
+
}),
|
|
2837
|
+
'cursor',
|
|
2838
|
+
)
|
|
2839
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
|
|
2840
|
+
})
|
|
2841
|
+
|
|
2842
|
+
it('makes a Claude compatibility copy inside Cursor a no-op', async () => {
|
|
2843
|
+
const h = harness()
|
|
2844
|
+
h.deps.env['CURSOR_PROJECT_DIR'] = h.deps.cwd
|
|
2845
|
+
|
|
2846
|
+
await hookRunCommand(
|
|
2847
|
+
h.deps,
|
|
2848
|
+
'session-start',
|
|
2849
|
+
stdin({ session_id: 'cursor-compat-copy', source: 'startup' }),
|
|
2850
|
+
'claude-code',
|
|
2851
|
+
)
|
|
2852
|
+
|
|
2853
|
+
expect(h.io.outLines).toEqual([])
|
|
2854
|
+
})
|
|
2855
|
+
})
|
|
2856
|
+
|
|
2501
2857
|
describe('user-prompt-submit hook', () => {
|
|
2858
|
+
it('activates once as a compatibility fallback when SessionStart was absent', async () => {
|
|
2859
|
+
const h = harness()
|
|
2860
|
+
|
|
2861
|
+
await hookRunCommand(
|
|
2862
|
+
h.deps,
|
|
2863
|
+
'user-prompt-submit',
|
|
2864
|
+
stdin({ session_id: 'presence-only' }),
|
|
2865
|
+
'claude-code',
|
|
2866
|
+
)
|
|
2867
|
+
|
|
2868
|
+
expect(h.io.outLines).toHaveLength(1)
|
|
2869
|
+
expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
|
|
2870
|
+
hookSpecificOutput: {
|
|
2871
|
+
hookEventName: 'UserPromptSubmit',
|
|
2872
|
+
additionalContext: expect.stringMatching(/Notifai.*session/i),
|
|
2873
|
+
},
|
|
2874
|
+
})
|
|
2875
|
+
expect(readSessionState('presence-only', h.env).last_prompt_at).toBe(NOW)
|
|
2876
|
+
|
|
2877
|
+
await hookRunCommand(
|
|
2878
|
+
h.deps,
|
|
2879
|
+
'user-prompt-submit',
|
|
2880
|
+
stdin({ session_id: 'presence-only' }),
|
|
2881
|
+
'claude-code',
|
|
2882
|
+
)
|
|
2883
|
+
expect(h.io.outLines).toHaveLength(1)
|
|
2884
|
+
})
|
|
2885
|
+
|
|
2886
|
+
it('stays silent when SessionStart already delivered activation', async () => {
|
|
2887
|
+
const h = harness()
|
|
2888
|
+
h.deps.codexSourcePid = 4242
|
|
2889
|
+
|
|
2890
|
+
await hookRunCommand(
|
|
2891
|
+
h.deps,
|
|
2892
|
+
'session-start',
|
|
2893
|
+
stdin({ session_id: 'lifecycle-owned' }),
|
|
2894
|
+
'codex',
|
|
2895
|
+
)
|
|
2896
|
+
h.io.outLines = []
|
|
2897
|
+
|
|
2898
|
+
await hookRunCommand(
|
|
2899
|
+
h.deps,
|
|
2900
|
+
'user-prompt-submit',
|
|
2901
|
+
stdin({ session_id: 'lifecycle-owned' }),
|
|
2902
|
+
'codex',
|
|
2903
|
+
)
|
|
2904
|
+
|
|
2905
|
+
expect(h.io.outLines).toEqual([])
|
|
2906
|
+
})
|
|
2907
|
+
|
|
2908
|
+
it('reactivates a resumed session owned by a new harness process', async () => {
|
|
2909
|
+
const h = harness()
|
|
2910
|
+
h.deps.codexSourcePid = 4242
|
|
2911
|
+
await hookRunCommand(
|
|
2912
|
+
h.deps,
|
|
2913
|
+
'session-start',
|
|
2914
|
+
stdin({ session_id: 'resumed-owner' }),
|
|
2915
|
+
'codex',
|
|
2916
|
+
)
|
|
2917
|
+
h.io.outLines = []
|
|
2918
|
+
h.deps.codexSourcePid = 4343
|
|
2919
|
+
|
|
2920
|
+
await hookRunCommand(
|
|
2921
|
+
h.deps,
|
|
2922
|
+
'user-prompt-submit',
|
|
2923
|
+
stdin({ session_id: 'resumed-owner' }),
|
|
2924
|
+
'codex',
|
|
2925
|
+
)
|
|
2926
|
+
|
|
2927
|
+
expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
|
|
2928
|
+
hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
|
|
2929
|
+
})
|
|
2930
|
+
})
|
|
2931
|
+
|
|
2932
|
+
it('uses the adapter-propagated harness owner across per-hook CLI processes', async () => {
|
|
2933
|
+
const h = harness()
|
|
2934
|
+
h.env['NOTIFAI_HOOK_SOURCE_PID'] = '5252'
|
|
2935
|
+
|
|
2936
|
+
await hookRunCommand(
|
|
2937
|
+
h.deps,
|
|
2938
|
+
'user-prompt-submit',
|
|
2939
|
+
stdin({ session_id: 'adapter-owned' }),
|
|
2940
|
+
'codex',
|
|
2941
|
+
)
|
|
2942
|
+
h.io.outLines = []
|
|
2943
|
+
await hookRunCommand(
|
|
2944
|
+
h.deps,
|
|
2945
|
+
'user-prompt-submit',
|
|
2946
|
+
stdin({ session_id: 'adapter-owned' }),
|
|
2947
|
+
'codex',
|
|
2948
|
+
)
|
|
2949
|
+
expect(h.io.outLines).toEqual([])
|
|
2950
|
+
|
|
2951
|
+
h.env['NOTIFAI_HOOK_SOURCE_PID'] = '5353'
|
|
2952
|
+
await hookRunCommand(
|
|
2953
|
+
h.deps,
|
|
2954
|
+
'user-prompt-submit',
|
|
2955
|
+
stdin({ session_id: 'adapter-owned' }),
|
|
2956
|
+
'codex',
|
|
2957
|
+
)
|
|
2958
|
+
expect(h.io.outLines).toHaveLength(1)
|
|
2959
|
+
})
|
|
2960
|
+
|
|
2961
|
+
it('does not let a worker lifecycle suppress parent prompt activation', async () => {
|
|
2962
|
+
const h = harness()
|
|
2963
|
+
h.deps.codexSourcePid = 4242
|
|
2964
|
+
await hookRunCommand(
|
|
2965
|
+
h.deps,
|
|
2966
|
+
'subagent-start',
|
|
2967
|
+
stdin({ session_id: 'shared-parent-worker' }),
|
|
2968
|
+
'codex',
|
|
2969
|
+
)
|
|
2970
|
+
h.io.outLines = []
|
|
2971
|
+
|
|
2972
|
+
await hookRunCommand(
|
|
2973
|
+
h.deps,
|
|
2974
|
+
'user-prompt-submit',
|
|
2975
|
+
stdin({ session_id: 'shared-parent-worker' }),
|
|
2976
|
+
'codex',
|
|
2977
|
+
)
|
|
2978
|
+
|
|
2979
|
+
expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
|
|
2980
|
+
hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
|
|
2981
|
+
})
|
|
2982
|
+
})
|
|
2983
|
+
|
|
2984
|
+
it('activates before config and authentication readiness', async () => {
|
|
2985
|
+
const h = harness()
|
|
2986
|
+
h.deps.cwd = path.join(h.deps.cwd, 'missing-project')
|
|
2987
|
+
h.deps.store.load = () => null
|
|
2988
|
+
|
|
2989
|
+
await hookRunCommand(
|
|
2990
|
+
h.deps,
|
|
2991
|
+
'user-prompt-submit',
|
|
2992
|
+
stdin({ session_id: 'first-run', cwd: h.deps.cwd }),
|
|
2993
|
+
'codex',
|
|
2994
|
+
)
|
|
2995
|
+
|
|
2996
|
+
expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
|
|
2997
|
+
hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
|
|
2998
|
+
})
|
|
2999
|
+
})
|
|
3000
|
+
|
|
2502
3001
|
it('records presence', async () => {
|
|
2503
3002
|
const h = harness()
|
|
2504
3003
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's10' }))
|
|
@@ -2564,7 +3063,7 @@ describe('user-prompt-submit hook', () => {
|
|
|
2564
3063
|
expect(h.io.errLines.join('\n')).toMatch(/not paired|credential/i)
|
|
2565
3064
|
})
|
|
2566
3065
|
|
|
2567
|
-
it('preserves a delivered question through a second ask and
|
|
3066
|
+
it('preserves a delivered question through a second ask and an unrelated prompt', async () => {
|
|
2568
3067
|
const h = harness([])
|
|
2569
3068
|
const live = {
|
|
2570
3069
|
question: 'Ship it?',
|
|
@@ -2575,10 +3074,6 @@ describe('user-prompt-submit hook', () => {
|
|
|
2575
3074
|
}
|
|
2576
3075
|
writeSessionState('transition', h.env, { last_prompt_at: AWAY, pending: [live] })
|
|
2577
3076
|
|
|
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
3077
|
registerQuestion('transition', h.env, { question: 'Deploy it?' })
|
|
2583
3078
|
const queued = readSessionState('transition', h.env)
|
|
2584
3079
|
expect(queued.retiring ?? []).toEqual([])
|
|
@@ -2587,35 +3082,22 @@ describe('user-prompt-submit hook', () => {
|
|
|
2587
3082
|
await hookRunCommand(
|
|
2588
3083
|
h.deps,
|
|
2589
3084
|
'user-prompt-submit',
|
|
2590
|
-
stdin({
|
|
3085
|
+
stdin({
|
|
3086
|
+
session_id: 'transition',
|
|
3087
|
+
cwd: h.deps.cwd,
|
|
3088
|
+
prompt: 'keep going on the billing work',
|
|
3089
|
+
}),
|
|
2591
3090
|
'claude-code',
|
|
2592
3091
|
)
|
|
2593
3092
|
|
|
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
|
-
})
|
|
3093
|
+
expect(h.recorder.closed).toEqual([])
|
|
2610
3094
|
expect(
|
|
2611
|
-
h.recorder.submitted.filter((s) => s
|
|
2612
|
-
).toHaveLength(
|
|
3095
|
+
h.recorder.submitted.filter((s) => isRetirementSubmit(s)),
|
|
3096
|
+
).toHaveLength(0)
|
|
2613
3097
|
expect(readSessionState('transition', h.env).pending?.map((entry) => entry.question)).toEqual([
|
|
3098
|
+
'Ship it?',
|
|
2614
3099
|
'Deploy it?',
|
|
2615
3100
|
])
|
|
2616
|
-
|
|
2617
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
|
|
2618
|
-
expect(readSessionState('transition', h.env).pending).toBeUndefined()
|
|
2619
3101
|
})
|
|
2620
3102
|
|
|
2621
3103
|
it('retires a real timed-out Stop before a later prompt can accept an answer', async () => {
|
|
@@ -2625,14 +3107,14 @@ describe('user-prompt-submit hook', () => {
|
|
|
2625
3107
|
|
|
2626
3108
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
|
|
2627
3109
|
expect(readSessionState('s11', h.env).pending).toBeUndefined()
|
|
2628
|
-
|
|
2629
|
-
(entry) => entry.draft.event === 'question_retired',
|
|
2630
|
-
).length
|
|
3110
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
2631
3111
|
|
|
2632
3112
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's11', cwd: '/repo' }))
|
|
2633
3113
|
|
|
2634
3114
|
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
2635
|
-
expect(
|
|
3115
|
+
expect(
|
|
3116
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3117
|
+
).toHaveLength(0)
|
|
2636
3118
|
})
|
|
2637
3119
|
|
|
2638
3120
|
it('marks the retirement done/answered_elsewhere so it ships silently', async () => {
|
|
@@ -2651,16 +3133,16 @@ describe('user-prompt-submit hook', () => {
|
|
|
2651
3133
|
}],
|
|
2652
3134
|
})
|
|
2653
3135
|
|
|
2654
|
-
await hookRunCommand(
|
|
3136
|
+
await hookRunCommand(
|
|
3137
|
+
h.deps,
|
|
3138
|
+
'user-prompt-submit',
|
|
3139
|
+
stdin({ session_id: 's15', cwd: '/repo', prompt: 'done' }),
|
|
3140
|
+
)
|
|
2655
3141
|
|
|
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
|
-
})
|
|
3142
|
+
expect(h.recorder.closed).toContain('req_live')
|
|
3143
|
+
expect(
|
|
3144
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3145
|
+
).toHaveLength(0)
|
|
2664
3146
|
})
|
|
2665
3147
|
|
|
2666
3148
|
it('retires as done/answered when the answer came from a device', async () => {
|
|
@@ -2676,12 +3158,71 @@ describe('user-prompt-submit hook', () => {
|
|
|
2676
3158
|
stdin({ session_id: 's16', stop_hook_active: true }),
|
|
2677
3159
|
)
|
|
2678
3160
|
|
|
2679
|
-
|
|
2680
|
-
expect(
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
3161
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
3162
|
+
expect(
|
|
3163
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3164
|
+
).toHaveLength(0)
|
|
3165
|
+
})
|
|
3166
|
+
|
|
3167
|
+
it('retires only the outstanding question a prompt uniquely answers', async () => {
|
|
3168
|
+
const h = harness([])
|
|
3169
|
+
writeSessionState('prompt-match', h.env, {
|
|
3170
|
+
last_prompt_at: AWAY,
|
|
3171
|
+
pending: [
|
|
3172
|
+
{
|
|
3173
|
+
question: 'Ship it?',
|
|
3174
|
+
questions: [
|
|
3175
|
+
{
|
|
3176
|
+
id: 'q1',
|
|
3177
|
+
text: 'Ship it?',
|
|
3178
|
+
choices: [
|
|
3179
|
+
{ id: 'ship', label: 'Ship it' },
|
|
3180
|
+
{ id: 'wait', label: 'Wait' },
|
|
3181
|
+
],
|
|
3182
|
+
},
|
|
3183
|
+
],
|
|
3184
|
+
request_id: 'req_live',
|
|
3185
|
+
collapse_key: 'collapse-live',
|
|
3186
|
+
device_ids: ['dev_iphone'],
|
|
3187
|
+
reply_deadline_at: NOW + 60_000,
|
|
3188
|
+
},
|
|
3189
|
+
],
|
|
3190
|
+
})
|
|
3191
|
+
|
|
3192
|
+
await hookRunCommand(
|
|
3193
|
+
h.deps,
|
|
3194
|
+
'user-prompt-submit',
|
|
3195
|
+
stdin({ session_id: 'prompt-match', prompt: 'Ship it' }),
|
|
3196
|
+
)
|
|
3197
|
+
|
|
3198
|
+
expect(h.recorder.closed).toContain('req_live')
|
|
3199
|
+
expect(readSessionState('prompt-match', h.env).pending).toBeUndefined()
|
|
3200
|
+
})
|
|
3201
|
+
|
|
3202
|
+
it('leaves an unrelated prompt’s live question open for the reply window', async () => {
|
|
3203
|
+
const h = harness([])
|
|
3204
|
+
writeSessionState('prompt-unrelated', h.env, {
|
|
3205
|
+
last_prompt_at: AWAY,
|
|
3206
|
+
pending: [{
|
|
3207
|
+
question: 'Ship it?',
|
|
3208
|
+
request_id: 'req_live',
|
|
3209
|
+
collapse_key: 'collapse-live',
|
|
3210
|
+
device_ids: ['dev_iphone'],
|
|
3211
|
+
reply_deadline_at: NOW + 60_000,
|
|
3212
|
+
}],
|
|
2684
3213
|
})
|
|
3214
|
+
|
|
3215
|
+
await hookRunCommand(
|
|
3216
|
+
h.deps,
|
|
3217
|
+
'user-prompt-submit',
|
|
3218
|
+
stdin({
|
|
3219
|
+
session_id: 'prompt-unrelated',
|
|
3220
|
+
prompt: 'continue investigating the billing failure on staging',
|
|
3221
|
+
}),
|
|
3222
|
+
)
|
|
3223
|
+
|
|
3224
|
+
expect(h.recorder.closed).toEqual([])
|
|
3225
|
+
expect(readSessionState('prompt-unrelated', h.env).pending?.[0]?.request_id).toBe('req_live')
|
|
2685
3226
|
})
|
|
2686
3227
|
|
|
2687
3228
|
it('publishes a session pointer so a plain `notifai ask` can find itself', async () => {
|
|
@@ -2762,6 +3303,83 @@ describe('user-prompt-submit hook', () => {
|
|
|
2762
3303
|
})
|
|
2763
3304
|
})
|
|
2764
3305
|
|
|
3306
|
+
describe('reconciling a conversation answer', () => {
|
|
3307
|
+
it('matches a unique closed choice and a lone short confirmation', () => {
|
|
3308
|
+
const choiceQuestion: PendingQuestion = {
|
|
3309
|
+
question: 'Ship it?',
|
|
3310
|
+
questions: [
|
|
3311
|
+
{
|
|
3312
|
+
id: 'q1',
|
|
3313
|
+
text: 'Ship it?',
|
|
3314
|
+
choices: [
|
|
3315
|
+
{ id: 'ship', label: 'Ship it' },
|
|
3316
|
+
{ id: 'wait', label: 'Wait' },
|
|
3317
|
+
],
|
|
3318
|
+
},
|
|
3319
|
+
],
|
|
3320
|
+
}
|
|
3321
|
+
const freeText: PendingQuestion = { question: 'Create the key?' }
|
|
3322
|
+
expect(pendingAnsweredByPrompt('Ship it', [choiceQuestion]).map((entry) => entry.question)).toEqual([
|
|
3323
|
+
'Ship it?',
|
|
3324
|
+
])
|
|
3325
|
+
expect(pendingAnsweredByPrompt('done', [freeText])).toEqual([freeText])
|
|
3326
|
+
expect(pendingAnsweredByPrompt('done', [choiceQuestion, freeText])).toEqual([])
|
|
3327
|
+
expect(pendingAnsweredByPrompt('keep going on the billing work', [freeText])).toEqual([])
|
|
3328
|
+
})
|
|
3329
|
+
|
|
3330
|
+
it('does not push a conversation-answered question after a newer one is registered', async () => {
|
|
3331
|
+
const h = harness([])
|
|
3332
|
+
writeSessionState('stale-then-new', h.env, { last_prompt_at: AWAY })
|
|
3333
|
+
registerQuestion('stale-then-new', h.env, { question: 'Create the Apple IAP key?' })
|
|
3334
|
+
|
|
3335
|
+
await hookRunCommand(
|
|
3336
|
+
h.deps,
|
|
3337
|
+
'user-prompt-submit',
|
|
3338
|
+
stdin({ session_id: 'stale-then-new', prompt: 'done' }),
|
|
3339
|
+
)
|
|
3340
|
+
expect(readSessionState('stale-then-new', h.env).pending).toBeUndefined()
|
|
3341
|
+
|
|
3342
|
+
registerQuestion('stale-then-new', h.env, { question: 'Which ASN endpoint?' })
|
|
3343
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'stale-then-new' }))
|
|
3344
|
+
|
|
3345
|
+
const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
|
|
3346
|
+
expect(questions.map((entry) => entry.draft.presentation.body)).toEqual(['Which ASN endpoint?'])
|
|
3347
|
+
})
|
|
3348
|
+
|
|
3349
|
+
it('reserves a close-fence budget when the owner deadline is nearly now', async () => {
|
|
3350
|
+
const h = harness([])
|
|
3351
|
+
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
3352
|
+
mkdirSync(dir, { recursive: true })
|
|
3353
|
+
writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
|
|
3354
|
+
writeSessionState('near-zero', h.env, {
|
|
3355
|
+
last_prompt_at: AWAY,
|
|
3356
|
+
pending: [{
|
|
3357
|
+
question: 'Ship it?',
|
|
3358
|
+
asked_at: NOW - 60_000,
|
|
3359
|
+
request_id: 'req_live',
|
|
3360
|
+
collapse_key: 'collapse-live',
|
|
3361
|
+
device_ids: ['dev_iphone'],
|
|
3362
|
+
reply_deadline_at: NOW + 4_001,
|
|
3363
|
+
owner_deadline_at: NOW + 4_001,
|
|
3364
|
+
}],
|
|
3365
|
+
})
|
|
3366
|
+
|
|
3367
|
+
await hookRunCommand(
|
|
3368
|
+
{ ...h.deps, logger: createLogger({ env: h.env, cmd: 'hook stop' }) },
|
|
3369
|
+
'stop',
|
|
3370
|
+
stdin({ session_id: 'near-zero', cwd: h.deps.cwd }),
|
|
3371
|
+
)
|
|
3372
|
+
|
|
3373
|
+
expect(h.recorder.closed).toContain('req_live')
|
|
3374
|
+
expect(
|
|
3375
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3376
|
+
).toHaveLength(0)
|
|
3377
|
+
expect(h.deps.now?.()).toBeLessThan(NOW + 10_000)
|
|
3378
|
+
const records = readLogRecords(h.env, { event: ['hook.retirement'] }).records
|
|
3379
|
+
expect(records.some((record) => record.data?.['proven'] === true)).toBe(true)
|
|
3380
|
+
})
|
|
3381
|
+
})
|
|
3382
|
+
|
|
2765
3383
|
describe('session-end hook', () => {
|
|
2766
3384
|
it('drops local state without touching the network and records the lifecycle', async () => {
|
|
2767
3385
|
const h = harness()
|
|
@@ -2805,6 +3423,7 @@ describe('session-end hook', () => {
|
|
|
2805
3423
|
expect(JSON.parse(readFileSync(stateFile, 'utf8'))).toEqual({
|
|
2806
3424
|
future_root: { retained: true },
|
|
2807
3425
|
acknowledgement_due: [acknowledgement],
|
|
3426
|
+
session_id: sessionId,
|
|
2808
3427
|
})
|
|
2809
3428
|
})
|
|
2810
3429
|
|
|
@@ -3029,7 +3648,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
3029
3648
|
'claude-code',
|
|
3030
3649
|
)
|
|
3031
3650
|
|
|
3032
|
-
const question = h.recorder.submitted.find((entry) => entry
|
|
3651
|
+
const question = h.recorder.submitted.find((entry) => isQuestionSubmit(entry))
|
|
3033
3652
|
expect(question?.draft.source).toEqual({
|
|
3034
3653
|
session_id: 'session-branch-transition',
|
|
3035
3654
|
session_label: 'Branch transition',
|
|
@@ -3051,15 +3670,10 @@ describe('telling concurrent agents apart', () => {
|
|
|
3051
3670
|
'claude-code',
|
|
3052
3671
|
)
|
|
3053
3672
|
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
session_id: 'session-branch-transition',
|
|
3059
|
-
session_label: 'Branch transition',
|
|
3060
|
-
harness: 'claude-code',
|
|
3061
|
-
branch: 'branch-a',
|
|
3062
|
-
})
|
|
3673
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
3674
|
+
expect(
|
|
3675
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3676
|
+
).toHaveLength(0)
|
|
3063
3677
|
})
|
|
3064
3678
|
|
|
3065
3679
|
it('clears registration-time branch and worktree on a detached-HEAD hook event', async () => {
|
|
@@ -3089,7 +3703,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
3089
3703
|
'claude-code',
|
|
3090
3704
|
)
|
|
3091
3705
|
|
|
3092
|
-
const question = h.recorder.submitted.find((entry) => entry
|
|
3706
|
+
const question = h.recorder.submitted.find((entry) => isQuestionSubmit(entry))
|
|
3093
3707
|
expect(question?.draft.source).toEqual({
|
|
3094
3708
|
session_id: 'session-detached',
|
|
3095
3709
|
session_label: 'Detached review',
|
|
@@ -3125,7 +3739,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
3125
3739
|
'claude-code',
|
|
3126
3740
|
)
|
|
3127
3741
|
const question = h.recorder.submitted.find(
|
|
3128
|
-
(entry) => entry
|
|
3742
|
+
(entry) => isQuestionSubmit(entry),
|
|
3129
3743
|
)
|
|
3130
3744
|
expect(question?.draft.source).toEqual({
|
|
3131
3745
|
session_id: 'session-worktree-transition',
|
|
@@ -3147,15 +3761,10 @@ describe('telling concurrent agents apart', () => {
|
|
|
3147
3761
|
}),
|
|
3148
3762
|
'claude-code',
|
|
3149
3763
|
)
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
session_id: 'session-worktree-transition',
|
|
3155
|
-
session_label: 'Worktree transition',
|
|
3156
|
-
harness: 'claude-code',
|
|
3157
|
-
branch: 'main',
|
|
3158
|
-
})
|
|
3764
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
3765
|
+
expect(
|
|
3766
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3767
|
+
).toHaveLength(0)
|
|
3159
3768
|
} finally {
|
|
3160
3769
|
runFixtureGit(h.deps.cwd, 'worktree', 'remove', '--force', linked)
|
|
3161
3770
|
}
|
|
@@ -3211,12 +3820,10 @@ describe('telling concurrent agents apart', () => {
|
|
|
3211
3820
|
stdin({ session_id: 'sess-abc', stop_hook_active: true }),
|
|
3212
3821
|
)
|
|
3213
3822
|
|
|
3214
|
-
|
|
3215
|
-
expect(
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
harness: 'claude-code',
|
|
3219
|
-
})
|
|
3823
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
3824
|
+
expect(
|
|
3825
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
3826
|
+
).toHaveLength(0)
|
|
3220
3827
|
})
|
|
3221
3828
|
|
|
3222
3829
|
it('preserves a human session label without displaying the opaque id', async () => {
|
|
@@ -3342,7 +3949,10 @@ describe('durable state writes', () => {
|
|
|
3342
3949
|
|
|
3343
3950
|
writeSessionState('truncated', h.env, { last_prompt_at: NOW })
|
|
3344
3951
|
|
|
3345
|
-
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({
|
|
3952
|
+
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({
|
|
3953
|
+
last_prompt_at: NOW,
|
|
3954
|
+
session_id: 'truncated',
|
|
3955
|
+
})
|
|
3346
3956
|
expect(readdirSync(path.dirname(file)).filter((name) => name.includes('.tmp'))).toEqual([])
|
|
3347
3957
|
})
|
|
3348
3958
|
|
|
@@ -3394,7 +4004,7 @@ describe('durable state writes', () => {
|
|
|
3394
4004
|
expect(readFileSync(queueTarget, 'utf8')).toBe('[]\n')
|
|
3395
4005
|
})
|
|
3396
4006
|
|
|
3397
|
-
it('persists each successful retirement before a later
|
|
4007
|
+
it('persists each successful retirement before a later close failure interrupts the drain', async () => {
|
|
3398
4008
|
const h = harness()
|
|
3399
4009
|
writeSessionState('partial-drain', h.env, {
|
|
3400
4010
|
retiring: [
|
|
@@ -3408,24 +4018,30 @@ describe('durable state writes', () => {
|
|
|
3408
4018
|
{
|
|
3409
4019
|
request_id: 'req_bad',
|
|
3410
4020
|
collapse_key: 'collapse-bad',
|
|
3411
|
-
device_ids: [],
|
|
4021
|
+
device_ids: ['dev_iphone'],
|
|
3412
4022
|
question: 'Second?',
|
|
3413
4023
|
state: 'expired',
|
|
3414
4024
|
},
|
|
3415
4025
|
],
|
|
3416
4026
|
})
|
|
4027
|
+
const base = h.deps.clientFactory!('https://test.notifai.invalid', 'Bearer x')
|
|
3417
4028
|
const ctx = {
|
|
3418
|
-
client:
|
|
4029
|
+
client: {
|
|
4030
|
+
...base,
|
|
4031
|
+
closeReplies: async (requestId: string) => {
|
|
4032
|
+
if (requestId === 'req_bad') throw new Error('offline')
|
|
4033
|
+
return base.closeReplies(requestId)
|
|
4034
|
+
},
|
|
4035
|
+
},
|
|
3419
4036
|
config: loadConfig({ cwd: h.deps.cwd, env: h.env }),
|
|
3420
4037
|
}
|
|
3421
4038
|
|
|
3422
|
-
await
|
|
3423
|
-
/missing its device identifiers/,
|
|
3424
|
-
)
|
|
4039
|
+
await drainRetirements(ctx, 'partial-drain', h.env)
|
|
3425
4040
|
|
|
3426
4041
|
expect(readSessionState('partial-drain', h.env).retiring?.map((entry) => entry.request_id)).toEqual([
|
|
3427
4042
|
'req_bad',
|
|
3428
4043
|
])
|
|
4044
|
+
expect(h.recorder.closed).toEqual(['req_good'])
|
|
3429
4045
|
})
|
|
3430
4046
|
})
|
|
3431
4047
|
|
|
@@ -3748,14 +4364,10 @@ describe('Claude Code Stop wake route', () => {
|
|
|
3748
4364
|
expect(wake.sent).toHaveLength(1)
|
|
3749
4365
|
expect(wake.resumed).toEqual([])
|
|
3750
4366
|
expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
|
|
3751
|
-
|
|
4367
|
+
expect(h.recorder.closed).toContain('req_existing')
|
|
3752
4368
|
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')
|
|
4369
|
+
h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
|
|
4370
|
+
).toHaveLength(0)
|
|
3759
4371
|
})
|
|
3760
4372
|
|
|
3761
4373
|
it('applies the continuation cap to a woken turn that never sets stop_hook_active', async () => {
|
|
@@ -3783,7 +4395,7 @@ describe('Claude Code Stop wake route', () => {
|
|
|
3783
4395
|
'claude-code',
|
|
3784
4396
|
)
|
|
3785
4397
|
|
|
3786
|
-
expect(h.recorder.submitted.filter((entry) => entry
|
|
4398
|
+
expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toEqual(
|
|
3787
4399
|
[],
|
|
3788
4400
|
)
|
|
3789
4401
|
expect(readSessionState('claude-route', h.env).pending?.[0]?.question).toBe('And after that?')
|