@raidiant/notifai 1.1.0 → 2.0.1
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/dist/client.d.ts +5 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +8 -1
- package/dist/commands.js +120 -15
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +15 -1
- package/dist/hooks.js +182 -12
- package/dist/hooks.js.map +1 -1
- package/dist/logging.d.ts +1 -1
- package/dist/logging.js +4 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +14 -3
- package/dist/main.js.map +1 -1
- package/package.json +11 -10
- package/src/client.test.ts +53 -0
- package/src/client.ts +24 -0
- package/src/commands.test.ts +252 -5
- package/src/commands.ts +158 -13
- package/src/hooks.test.ts +150 -2
- package/src/hooks.ts +240 -12
- package/src/logging.ts +4 -0
- package/src/main.ts +17 -2
- package/src/skill.test.ts +32 -1
package/src/hooks.test.ts
CHANGED
|
@@ -89,6 +89,9 @@ interface Recorder {
|
|
|
89
89
|
beforeReplies?: () => Promise<void>
|
|
90
90
|
/** Per-request answer stream for multi-question ownership tests. */
|
|
91
91
|
repliesFor?: (requestId: string) => ReplyView[]
|
|
92
|
+
acknowledgementRequiredFor?: (requestId: string) => boolean
|
|
93
|
+
acknowledged?: Set<string>
|
|
94
|
+
acknowledgementChecks?: string[]
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
@@ -115,7 +118,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
115
118
|
platform: 'ios' as const,
|
|
116
119
|
permission_status: 'authorized',
|
|
117
120
|
registration_healthy: true,
|
|
118
|
-
reply_protocol_version:
|
|
121
|
+
reply_protocol_version: 2,
|
|
119
122
|
last_seen_at: null,
|
|
120
123
|
},
|
|
121
124
|
{
|
|
@@ -124,13 +127,36 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
124
127
|
platform: 'macos' as const,
|
|
125
128
|
permission_status: 'authorized',
|
|
126
129
|
registration_healthy: true,
|
|
127
|
-
reply_protocol_version:
|
|
130
|
+
reply_protocol_version: 2,
|
|
128
131
|
last_seen_at: null,
|
|
129
132
|
},
|
|
130
133
|
],
|
|
131
134
|
}),
|
|
132
135
|
capabilities: notUsed,
|
|
133
136
|
evidence: notUsed,
|
|
137
|
+
putAgentAcknowledgement: async (requestId, body) => {
|
|
138
|
+
recorder.acknowledged ??= new Set()
|
|
139
|
+
recorder.acknowledged.add(requestId)
|
|
140
|
+
return {
|
|
141
|
+
status: 'recorded',
|
|
142
|
+
agent_acknowledgement: {
|
|
143
|
+
text: body.text,
|
|
144
|
+
created_at: new Date(NOW).toISOString(),
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
agentAcknowledgement: async (requestId) => {
|
|
149
|
+
recorder.acknowledgementChecks ??= []
|
|
150
|
+
recorder.acknowledgementChecks.push(requestId)
|
|
151
|
+
const required = recorder.acknowledgementRequiredFor?.(requestId) ?? true
|
|
152
|
+
return {
|
|
153
|
+
request_id: requestId,
|
|
154
|
+
agent_acknowledgement_required: required,
|
|
155
|
+
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
156
|
+
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
157
|
+
: null,
|
|
158
|
+
}
|
|
159
|
+
},
|
|
134
160
|
createMediaUpload: notUsed,
|
|
135
161
|
uploadMedia: notUsed,
|
|
136
162
|
health: async () => true,
|
|
@@ -145,6 +171,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
145
171
|
return {
|
|
146
172
|
request_id: requestId,
|
|
147
173
|
reply_expires_at: new Date(NOW + 480_000).toISOString(),
|
|
174
|
+
agent_acknowledgement_required:
|
|
175
|
+
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
148
176
|
replayed: false,
|
|
149
177
|
overall: 'provider_accepted_all',
|
|
150
178
|
deliveries: [],
|
|
@@ -156,6 +184,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
156
184
|
return ({
|
|
157
185
|
request_id: requestId,
|
|
158
186
|
reply_expires_at: null,
|
|
187
|
+
agent_acknowledgement_required:
|
|
188
|
+
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
189
|
+
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
190
|
+
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
191
|
+
: null,
|
|
159
192
|
replies: recordedReplies(requestId),
|
|
160
193
|
}) satisfies ListRepliesResponse
|
|
161
194
|
},
|
|
@@ -164,6 +197,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
164
197
|
return {
|
|
165
198
|
request_id: requestId,
|
|
166
199
|
reply_expires_at: new Date(NOW).toISOString(),
|
|
200
|
+
agent_acknowledgement_required:
|
|
201
|
+
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
202
|
+
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
203
|
+
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
204
|
+
: null,
|
|
167
205
|
replies: recordedReplies(requestId),
|
|
168
206
|
} satisfies ListRepliesResponse
|
|
169
207
|
},
|
|
@@ -842,6 +880,8 @@ describe('nagging guards', () => {
|
|
|
842
880
|
registerQuestion('n3', h.env, { question: 'First question?' }, NOW)
|
|
843
881
|
|
|
844
882
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3' }))
|
|
883
|
+
h.recorder.acknowledged ??= new Set()
|
|
884
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
845
885
|
await h.deps.sleep?.(1)
|
|
846
886
|
registerQuestion('n3', h.env, { question: 'Follow-up question?' }, (h.deps.now?.() ?? NOW))
|
|
847
887
|
h.io.outLines = []
|
|
@@ -1215,6 +1255,8 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1215
1255
|
])
|
|
1216
1256
|
|
|
1217
1257
|
answers.set('req_hook_2', [reply({ text: 'Second answer' })])
|
|
1258
|
+
h.recorder.acknowledged ??= new Set()
|
|
1259
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
1218
1260
|
h.io.outLines = []
|
|
1219
1261
|
await hookRunCommand(
|
|
1220
1262
|
h.deps,
|
|
@@ -1621,6 +1663,8 @@ describe('several questions in flight', () => {
|
|
|
1621
1663
|
expect(JSON.parse(h.io.outLines.at(-1)!).reason).toContain('Ship it')
|
|
1622
1664
|
expect(readSessionState('answer-handoff', h.env).accepted).toBeDefined()
|
|
1623
1665
|
|
|
1666
|
+
h.recorder.acknowledged ??= new Set()
|
|
1667
|
+
h.recorder.acknowledged.add('req_existing')
|
|
1624
1668
|
h.io.outLines = []
|
|
1625
1669
|
await hookRunCommand(
|
|
1626
1670
|
h.deps,
|
|
@@ -1638,6 +1682,104 @@ describe('several questions in flight', () => {
|
|
|
1638
1682
|
).toBe('answered')
|
|
1639
1683
|
})
|
|
1640
1684
|
|
|
1685
|
+
it('blocks Stop with the exact command until a required Agent Acknowledgement is recorded', async () => {
|
|
1686
|
+
const h = harness([reply({ text: 'Ship it' })])
|
|
1687
|
+
writeSessionState('ack-required', h.env, {
|
|
1688
|
+
pending: [
|
|
1689
|
+
{
|
|
1690
|
+
question: 'Ship it?',
|
|
1691
|
+
request_id: 'req_ack_required',
|
|
1692
|
+
collapse_key: 'collapse-ack-required',
|
|
1693
|
+
device_ids: ['dev_iphone'],
|
|
1694
|
+
reply_deadline_at: NOW + 60_000,
|
|
1695
|
+
},
|
|
1696
|
+
],
|
|
1697
|
+
})
|
|
1698
|
+
|
|
1699
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-required' }))
|
|
1700
|
+
expect(readSessionState('ack-required', h.env).acknowledgement_due).toEqual([
|
|
1701
|
+
{ request_id: 'req_ack_required', recorded_at: NOW },
|
|
1702
|
+
])
|
|
1703
|
+
|
|
1704
|
+
h.io.outLines = []
|
|
1705
|
+
await hookRunCommand(
|
|
1706
|
+
h.deps,
|
|
1707
|
+
'stop',
|
|
1708
|
+
stdin({ session_id: 'ack-required', stop_hook_active: true }),
|
|
1709
|
+
)
|
|
1710
|
+
|
|
1711
|
+
const blocked = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
|
|
1712
|
+
expect(blocked.decision).toBe('block')
|
|
1713
|
+
expect(blocked.reason).toContain('req_ack_required')
|
|
1714
|
+
expect(blocked.reason).toContain(
|
|
1715
|
+
'notifai acknowledge req_ack_required --text <text>',
|
|
1716
|
+
)
|
|
1717
|
+
expect(blocked.reason).toContain('concrete work')
|
|
1718
|
+
expect(readSessionState('ack-required', h.env).accepted).toBeUndefined()
|
|
1719
|
+
})
|
|
1720
|
+
|
|
1721
|
+
it('reconciles a server-recorded Agent Acknowledgement after a local clearing crash', async () => {
|
|
1722
|
+
const h = harness([])
|
|
1723
|
+
writeSessionState('ack-heal', h.env, {
|
|
1724
|
+
acknowledgement_due: [{ request_id: 'req_ack_heal', recorded_at: NOW }],
|
|
1725
|
+
})
|
|
1726
|
+
h.recorder.acknowledged = new Set(['req_ack_heal'])
|
|
1727
|
+
|
|
1728
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-heal' }))
|
|
1729
|
+
|
|
1730
|
+
expect(h.recorder.acknowledgementChecks).toEqual(['req_ack_heal'])
|
|
1731
|
+
expect(readSessionState('ack-heal', h.env).acknowledgement_due).toBeUndefined()
|
|
1732
|
+
expect(h.io.outLines).toEqual([])
|
|
1733
|
+
})
|
|
1734
|
+
|
|
1735
|
+
it('records only the requests whose immutable snapshot requires acknowledgement', async () => {
|
|
1736
|
+
const h = harness([])
|
|
1737
|
+
repliesByRequest(
|
|
1738
|
+
h,
|
|
1739
|
+
new Map([
|
|
1740
|
+
['req_hook_1', [reply({ text: 'Ship it' })]],
|
|
1741
|
+
['req_hook_2', [reply({ text: 'Staging' })]],
|
|
1742
|
+
]),
|
|
1743
|
+
)
|
|
1744
|
+
h.recorder.acknowledgementRequiredFor = (requestId) =>
|
|
1745
|
+
h.recorder.aliases.get(requestId) === 1
|
|
1746
|
+
writeSessionState('ack-mixed', h.env, { last_prompt_at: AWAY })
|
|
1747
|
+
registerQuestion('ack-mixed', h.env, { question: 'Ship it?' }, NOW)
|
|
1748
|
+
registerQuestion('ack-mixed', h.env, { question: 'Deploy where?' }, NOW + 1)
|
|
1749
|
+
|
|
1750
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-mixed' }))
|
|
1751
|
+
|
|
1752
|
+
const state = readSessionState('ack-mixed', h.env)
|
|
1753
|
+
expect(state.acknowledgement_due?.map((entry) => entry.request_id)).toEqual([
|
|
1754
|
+
h.recorder.receipts[0],
|
|
1755
|
+
])
|
|
1756
|
+
const output = JSON.parse(h.io.outLines[0] ?? '{}') as { reason?: string }
|
|
1757
|
+
expect(output.reason).toContain(h.recorder.receipts[0]!)
|
|
1758
|
+
expect(output.reason).not.toContain(h.recorder.receipts[1]!)
|
|
1759
|
+
})
|
|
1760
|
+
|
|
1761
|
+
it('lists every required request and command in a multi-answer continuation', async () => {
|
|
1762
|
+
const h = harness([])
|
|
1763
|
+
repliesByRequest(
|
|
1764
|
+
h,
|
|
1765
|
+
new Map([
|
|
1766
|
+
['req_hook_1', [reply({ text: 'Ship it' })]],
|
|
1767
|
+
['req_hook_2', [reply({ text: 'Staging' })]],
|
|
1768
|
+
]),
|
|
1769
|
+
)
|
|
1770
|
+
writeSessionState('ack-multiple', h.env, { last_prompt_at: AWAY })
|
|
1771
|
+
registerQuestion('ack-multiple', h.env, { question: 'Ship it?' }, NOW)
|
|
1772
|
+
registerQuestion('ack-multiple', h.env, { question: 'Deploy where?' }, NOW + 1)
|
|
1773
|
+
|
|
1774
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-multiple' }))
|
|
1775
|
+
|
|
1776
|
+
const output = JSON.parse(h.io.outLines[0] ?? '{}') as { reason?: string }
|
|
1777
|
+
for (const requestId of h.recorder.receipts) {
|
|
1778
|
+
expect(output.reason).toContain(requestId)
|
|
1779
|
+
expect(output.reason).toContain(`notifai acknowledge ${requestId} --text <text>`)
|
|
1780
|
+
}
|
|
1781
|
+
})
|
|
1782
|
+
|
|
1641
1783
|
it('resumes with a partial answer and keeps the rest registered', async () => {
|
|
1642
1784
|
const h = harness([])
|
|
1643
1785
|
const byRequest = new Map([['req_hook_1', [reply({ text: 'Ship it' })]]])
|
|
@@ -1658,6 +1800,8 @@ describe('several questions in flight', () => {
|
|
|
1658
1800
|
// The remaining question's answer arrives before the next turn ends; the
|
|
1659
1801
|
// late-answer path hands it over without asking anything twice.
|
|
1660
1802
|
byRequest.set('req_hook_2', [reply({ text: 'Staging' })])
|
|
1803
|
+
h.recorder.acknowledged ??= new Set()
|
|
1804
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
1661
1805
|
h.io.outLines = []
|
|
1662
1806
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi3', stop_hook_active: true }))
|
|
1663
1807
|
|
|
@@ -2179,6 +2323,8 @@ describe('user-prompt-submit hook', () => {
|
|
|
2179
2323
|
registerQuestion('s16', h.env, { question: 'Ship it?' })
|
|
2180
2324
|
|
|
2181
2325
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's16' }))
|
|
2326
|
+
h.recorder.acknowledged ??= new Set()
|
|
2327
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
2182
2328
|
await hookRunCommand(
|
|
2183
2329
|
h.deps,
|
|
2184
2330
|
'stop',
|
|
@@ -2399,6 +2545,8 @@ describe('telling concurrent agents apart', () => {
|
|
|
2399
2545
|
registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
|
|
2400
2546
|
|
|
2401
2547
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
2548
|
+
h.recorder.acknowledged ??= new Set()
|
|
2549
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
2402
2550
|
await hookRunCommand(
|
|
2403
2551
|
h.deps,
|
|
2404
2552
|
'stop',
|
package/src/hooks.ts
CHANGED
|
@@ -118,6 +118,18 @@ export interface SessionState {
|
|
|
118
118
|
* reaches the harness replays it instead of erasing it.
|
|
119
119
|
*/
|
|
120
120
|
accepted?: AcceptedAnswerDelivery
|
|
121
|
+
/**
|
|
122
|
+
* Required Agent Acknowledgements that the resumed agent still owes. This is
|
|
123
|
+
* separate from answer delivery: the answer journal may settle as soon as a
|
|
124
|
+
* harness accepts the continuation, while this obligation must survive until
|
|
125
|
+
* the service confirms the agent-authored follow-up exists.
|
|
126
|
+
*/
|
|
127
|
+
acknowledgement_due?: AcknowledgementDue[]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface AcknowledgementDue {
|
|
131
|
+
request_id: string
|
|
132
|
+
recorded_at: number
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
interface AcceptedAnswerDelivery {
|
|
@@ -784,6 +796,7 @@ export type GateReason =
|
|
|
784
796
|
| 'continuation-repeat'
|
|
785
797
|
| 'continuation-limit'
|
|
786
798
|
| 'delivery-limit'
|
|
799
|
+
| 'acknowledgement-required'
|
|
787
800
|
| 'notifications-off'
|
|
788
801
|
| 'claimed-elsewhere'
|
|
789
802
|
| 'no-devices'
|
|
@@ -977,7 +990,7 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
|
|
|
977
990
|
(device) =>
|
|
978
991
|
(device.platform === 'ios' || device.platform === 'macos') &&
|
|
979
992
|
device.registration_healthy &&
|
|
980
|
-
device.reply_protocol_version ===
|
|
993
|
+
device.reply_protocol_version === 2,
|
|
981
994
|
)
|
|
982
995
|
.filter((device) => configured === null || configured.includes(device.device_id))
|
|
983
996
|
.map((device) => device.device_id)
|
|
@@ -1340,6 +1353,8 @@ interface AnsweredPending {
|
|
|
1340
1353
|
pending: PendingQuestion
|
|
1341
1354
|
reply: ReplyView
|
|
1342
1355
|
replies: ReplyView[]
|
|
1356
|
+
/** Immutable server snapshot for this request, known after a replies/close response. */
|
|
1357
|
+
agent_acknowledgement_required?: boolean | undefined
|
|
1343
1358
|
}
|
|
1344
1359
|
|
|
1345
1360
|
interface FinalizedPending {
|
|
@@ -1364,7 +1379,15 @@ async function finalizePendings(
|
|
|
1364
1379
|
function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
|
|
1365
1380
|
const replies = finalized.response?.replies ?? []
|
|
1366
1381
|
const reply = replies.at(-1)
|
|
1367
|
-
return reply === undefined
|
|
1382
|
+
return reply === undefined
|
|
1383
|
+
? null
|
|
1384
|
+
: {
|
|
1385
|
+
pending: finalized.pending,
|
|
1386
|
+
reply,
|
|
1387
|
+
replies,
|
|
1388
|
+
agent_acknowledgement_required:
|
|
1389
|
+
finalized.response?.agent_acknowledgement_required,
|
|
1390
|
+
}
|
|
1368
1391
|
}
|
|
1369
1392
|
|
|
1370
1393
|
/** Persist and surface one answer without letting the two representations drift. */
|
|
@@ -1476,13 +1499,45 @@ function answerContext(answered: AnsweredPending): string {
|
|
|
1476
1499
|
* tied to the question that asked it, with a truthful note about anything
|
|
1477
1500
|
* still waiting.
|
|
1478
1501
|
*/
|
|
1502
|
+
function acknowledgementCommand(requestId: string): string {
|
|
1503
|
+
return `notifai acknowledge ${requestId} --text <text>`
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function acknowledgementContext(answered: AnsweredPending[]): string {
|
|
1507
|
+
const due = answered.filter(
|
|
1508
|
+
(entry) =>
|
|
1509
|
+
entry.agent_acknowledgement_required === true && entry.pending.request_id !== undefined,
|
|
1510
|
+
)
|
|
1511
|
+
if (due.length === 0) {
|
|
1512
|
+
return ' Agent Acknowledgement is not required for the answered request(s).'
|
|
1513
|
+
}
|
|
1514
|
+
if (due.length === 1) {
|
|
1515
|
+
const requestId = due[0]!.pending.request_id!
|
|
1516
|
+
return (
|
|
1517
|
+
` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
|
|
1518
|
+
`\`${acknowledgementCommand(requestId)}\` with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient.`
|
|
1519
|
+
)
|
|
1520
|
+
}
|
|
1521
|
+
const commands = due
|
|
1522
|
+
.map((entry) => {
|
|
1523
|
+
const requestId = entry.pending.request_id!
|
|
1524
|
+
return `- ${requestId}: \`${acknowledgementCommand(requestId)}\``
|
|
1525
|
+
})
|
|
1526
|
+
.join('\n')
|
|
1527
|
+
return (
|
|
1528
|
+
` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below with non-empty text saying what concrete work you will do because of that reply; a bare acknowledgement is insufficient:\n` +
|
|
1529
|
+
commands
|
|
1530
|
+
)
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1479
1533
|
function answersContext(answered: AnsweredPending[], remaining: number): string {
|
|
1480
1534
|
const tail =
|
|
1481
1535
|
remaining > 0
|
|
1482
1536
|
? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
|
|
1483
1537
|
: ''
|
|
1538
|
+
const guidance = acknowledgementContext(answered)
|
|
1484
1539
|
if (answered.length === 1) {
|
|
1485
|
-
return answerContext(answered[0]!) + tail
|
|
1540
|
+
return answerContext(answered[0]!) + tail + guidance
|
|
1486
1541
|
}
|
|
1487
1542
|
const lines = answered.map(({ pending, replies }) => {
|
|
1488
1543
|
const latest = replies.at(-1)!
|
|
@@ -1494,7 +1549,7 @@ function answersContext(answered: AnsweredPending[], remaining: number): string
|
|
|
1494
1549
|
const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `
|
|
1495
1550
|
return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`
|
|
1496
1551
|
})
|
|
1497
|
-
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}`
|
|
1552
|
+
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}${guidance}`
|
|
1498
1553
|
}
|
|
1499
1554
|
|
|
1500
1555
|
function stopAnswerOutput(context: string): string {
|
|
@@ -1525,12 +1580,25 @@ function stageAcceptedAnswers(
|
|
|
1525
1580
|
const pendingRemaining = pendingList(current).filter(
|
|
1526
1581
|
(entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
|
|
1527
1582
|
)
|
|
1583
|
+
const acknowledgementDue = [...(current.acknowledgement_due ?? [])]
|
|
1584
|
+
for (const answer of answered) {
|
|
1585
|
+
const requestId = answer.pending.request_id
|
|
1586
|
+
if (
|
|
1587
|
+
answer.agent_acknowledgement_required === true &&
|
|
1588
|
+
requestId !== undefined &&
|
|
1589
|
+
!acknowledgementDue.some((entry) => entry.request_id === requestId)
|
|
1590
|
+
) {
|
|
1591
|
+
acknowledgementDue.push({ request_id: requestId, recorded_at: accepted.recorded_at })
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1528
1594
|
const next: SessionState = {
|
|
1529
1595
|
...current,
|
|
1530
1596
|
accepted,
|
|
1531
1597
|
}
|
|
1532
1598
|
if (pendingRemaining.length > 0) next.pending = pendingRemaining
|
|
1533
1599
|
else delete next.pending
|
|
1600
|
+
if (acknowledgementDue.length > 0) next.acknowledgement_due = acknowledgementDue
|
|
1601
|
+
else delete next.acknowledgement_due
|
|
1534
1602
|
return next
|
|
1535
1603
|
})
|
|
1536
1604
|
return accepted
|
|
@@ -1547,6 +1615,68 @@ function amendAcceptedAnswers(
|
|
|
1547
1615
|
)
|
|
1548
1616
|
}
|
|
1549
1617
|
|
|
1618
|
+
export function clearAcknowledgementObligation(
|
|
1619
|
+
sessionId: string,
|
|
1620
|
+
env: NodeJS.ProcessEnv,
|
|
1621
|
+
requestId: string,
|
|
1622
|
+
): boolean {
|
|
1623
|
+
let cleared = false
|
|
1624
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1625
|
+
const due = current.acknowledgement_due ?? []
|
|
1626
|
+
const remaining = due.filter((entry) => entry.request_id !== requestId)
|
|
1627
|
+
cleared = remaining.length !== due.length
|
|
1628
|
+
if (!cleared) return current
|
|
1629
|
+
const next = { ...current }
|
|
1630
|
+
if (remaining.length > 0) next.acknowledgement_due = remaining
|
|
1631
|
+
else delete next.acknowledgement_due
|
|
1632
|
+
return next
|
|
1633
|
+
})
|
|
1634
|
+
return cleared
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function acknowledgementBlockContext(due: readonly AcknowledgementDue[]): string {
|
|
1638
|
+
const commands = due
|
|
1639
|
+
.map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id)}\``)
|
|
1640
|
+
.join('\n')
|
|
1641
|
+
return (
|
|
1642
|
+
`Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
|
|
1643
|
+
`Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'} with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient:\n${commands}`
|
|
1644
|
+
)
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
async function reconcileAcknowledgementObligations(
|
|
1648
|
+
ctx: HookContext,
|
|
1649
|
+
sessionId: string,
|
|
1650
|
+
due: readonly AcknowledgementDue[],
|
|
1651
|
+
): Promise<AcknowledgementDue[]> {
|
|
1652
|
+
const unresolved: AcknowledgementDue[] = []
|
|
1653
|
+
for (const obligation of due) {
|
|
1654
|
+
try {
|
|
1655
|
+
const snapshot = await ctx.client.agentAcknowledgement(obligation.request_id, {
|
|
1656
|
+
waitSeconds: 0,
|
|
1657
|
+
})
|
|
1658
|
+
if (
|
|
1659
|
+
snapshot.agent_acknowledgement_required === false ||
|
|
1660
|
+
snapshot.agent_acknowledgement !== null
|
|
1661
|
+
) {
|
|
1662
|
+
clearAcknowledgementObligation(sessionId, ctx.env, obligation.request_id)
|
|
1663
|
+
} else {
|
|
1664
|
+
unresolved.push(obligation)
|
|
1665
|
+
}
|
|
1666
|
+
} catch (err) {
|
|
1667
|
+
unresolved.push(obligation)
|
|
1668
|
+
ctx.log?.error('hook.gate', {
|
|
1669
|
+
verdict: 'held',
|
|
1670
|
+
reason: 'acknowledgement-required',
|
|
1671
|
+
stage: 'reconcile-failed',
|
|
1672
|
+
request_id: obligation.request_id,
|
|
1673
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1674
|
+
})
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
return unresolved
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1550
1680
|
/**
|
|
1551
1681
|
* Retire the answered questions and close the journal.
|
|
1552
1682
|
*
|
|
@@ -1666,7 +1796,14 @@ export async function handleUserPromptSubmit(
|
|
|
1666
1796
|
`could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`,
|
|
1667
1797
|
)
|
|
1668
1798
|
}
|
|
1669
|
-
return {
|
|
1799
|
+
return {
|
|
1800
|
+
...entry,
|
|
1801
|
+
replies,
|
|
1802
|
+
reply: replies.at(-1)!,
|
|
1803
|
+
agent_acknowledgement_required:
|
|
1804
|
+
response?.agent_acknowledgement_required ??
|
|
1805
|
+
entry.agent_acknowledgement_required,
|
|
1806
|
+
}
|
|
1670
1807
|
})
|
|
1671
1808
|
if (answered.length > 0) {
|
|
1672
1809
|
stageAcceptedAnswers(
|
|
@@ -1800,6 +1937,27 @@ export async function runEscalationWaiter(
|
|
|
1800
1937
|
let state = readSessionState(sessionId, ctx.env)
|
|
1801
1938
|
if (state.accepted !== undefined) {
|
|
1802
1939
|
const accepted = state.accepted
|
|
1940
|
+
if (envelope.stop_hook_active === true && pendingList(state).length > 0) {
|
|
1941
|
+
const due = await reconcileAcknowledgementObligations(
|
|
1942
|
+
ctx,
|
|
1943
|
+
sessionId,
|
|
1944
|
+
state.acknowledgement_due ?? [],
|
|
1945
|
+
)
|
|
1946
|
+
if (due.length > 0) {
|
|
1947
|
+
gate(ctx, 'held', 'acknowledgement-required', {
|
|
1948
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
1949
|
+
})
|
|
1950
|
+
return {
|
|
1951
|
+
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1952
|
+
notes,
|
|
1953
|
+
log: {
|
|
1954
|
+
stage: 'acknowledgement-required',
|
|
1955
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
1956
|
+
},
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
state = readSessionState(sessionId, ctx.env)
|
|
1960
|
+
}
|
|
1803
1961
|
// Acknowledgement is per route, because routes end in different places.
|
|
1804
1962
|
//
|
|
1805
1963
|
// A blocking continuation ends at this process's stdout, which the
|
|
@@ -1820,6 +1978,54 @@ export async function runEscalationWaiter(
|
|
|
1820
1978
|
settleAcceptedAnswers(ctx, sessionId, accepted)
|
|
1821
1979
|
settledAnswerThisPass = true
|
|
1822
1980
|
state = readSessionState(sessionId, ctx.env)
|
|
1981
|
+
const currentAcceptedIds = new Set(
|
|
1982
|
+
accepted.answers.flatMap(({ pending }) =>
|
|
1983
|
+
pending.request_id === undefined ? [] : [pending.request_id],
|
|
1984
|
+
),
|
|
1985
|
+
)
|
|
1986
|
+
const due = await reconcileAcknowledgementObligations(
|
|
1987
|
+
ctx,
|
|
1988
|
+
sessionId,
|
|
1989
|
+
(state.acknowledgement_due ?? []).filter((entry) =>
|
|
1990
|
+
currentAcceptedIds.has(entry.request_id),
|
|
1991
|
+
),
|
|
1992
|
+
)
|
|
1993
|
+
if (due.length > 0) {
|
|
1994
|
+
gate(ctx, 'held', 'acknowledgement-required', {
|
|
1995
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
1996
|
+
})
|
|
1997
|
+
return {
|
|
1998
|
+
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1999
|
+
notes,
|
|
2000
|
+
log: {
|
|
2001
|
+
stage: 'acknowledgement-required',
|
|
2002
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
2003
|
+
},
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
state = readSessionState(sessionId, ctx.env)
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
if (state.accepted === undefined && (state.acknowledgement_due?.length ?? 0) > 0) {
|
|
2010
|
+
const due = await reconcileAcknowledgementObligations(
|
|
2011
|
+
ctx,
|
|
2012
|
+
sessionId,
|
|
2013
|
+
state.acknowledgement_due!,
|
|
2014
|
+
)
|
|
2015
|
+
if (due.length > 0) {
|
|
2016
|
+
gate(ctx, 'held', 'acknowledgement-required', {
|
|
2017
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
2018
|
+
})
|
|
2019
|
+
return {
|
|
2020
|
+
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
2021
|
+
notes,
|
|
2022
|
+
log: {
|
|
2023
|
+
stage: 'acknowledgement-required',
|
|
2024
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
2025
|
+
},
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
state = readSessionState(sessionId, ctx.env)
|
|
1823
2029
|
}
|
|
1824
2030
|
|
|
1825
2031
|
const pending = pendingList(state)
|
|
@@ -2014,7 +2220,14 @@ async function handleClaimedStop(
|
|
|
2014
2220
|
`could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`,
|
|
2015
2221
|
)
|
|
2016
2222
|
}
|
|
2017
|
-
return {
|
|
2223
|
+
return {
|
|
2224
|
+
...entry,
|
|
2225
|
+
replies,
|
|
2226
|
+
reply: replies.at(-1)!,
|
|
2227
|
+
agent_acknowledgement_required:
|
|
2228
|
+
response?.agent_acknowledgement_required ??
|
|
2229
|
+
entry.agent_acknowledgement_required,
|
|
2230
|
+
}
|
|
2018
2231
|
})
|
|
2019
2232
|
const accepted = stageAcceptedAnswers(
|
|
2020
2233
|
ctx,
|
|
@@ -2429,7 +2642,13 @@ async function escalate(
|
|
|
2429
2642
|
// unreachable, never discard the durable reply already observed.
|
|
2430
2643
|
const finalReplies = finalized.response?.replies ?? []
|
|
2431
2644
|
const authoritative = finalReplies.length > 0 ? finalReplies : replies
|
|
2432
|
-
answered.push({
|
|
2645
|
+
answered.push({
|
|
2646
|
+
pending: entry,
|
|
2647
|
+
reply: authoritative.at(-1)!,
|
|
2648
|
+
replies: authoritative,
|
|
2649
|
+
agent_acknowledgement_required:
|
|
2650
|
+
finalized.response?.agent_acknowledgement_required,
|
|
2651
|
+
})
|
|
2433
2652
|
if (finalized.response === null) {
|
|
2434
2653
|
notes.push(
|
|
2435
2654
|
`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`,
|
|
@@ -2491,19 +2710,28 @@ export function handleSessionEnd(
|
|
|
2491
2710
|
`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
|
|
2492
2711
|
)
|
|
2493
2712
|
}
|
|
2494
|
-
if (state.accepted !== undefined) {
|
|
2495
|
-
const preserved: SessionState = {
|
|
2713
|
+
if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
|
|
2714
|
+
const preserved: SessionState = {}
|
|
2715
|
+
if (state.accepted !== undefined) preserved.accepted = state.accepted
|
|
2716
|
+
if (state.acknowledgement_due !== undefined && state.acknowledgement_due.length > 0) {
|
|
2717
|
+
preserved.acknowledgement_due = state.acknowledgement_due
|
|
2718
|
+
}
|
|
2496
2719
|
if (state.last_prompt_at !== undefined) preserved.last_prompt_at = state.last_prompt_at
|
|
2497
2720
|
if (state.last_stop_at !== undefined) preserved.last_stop_at = state.last_stop_at
|
|
2498
2721
|
if (state.continuation !== undefined) preserved.continuation = state.continuation
|
|
2499
2722
|
writeSessionState(sessionId, env, preserved)
|
|
2500
|
-
notes.push(
|
|
2723
|
+
notes.push(
|
|
2724
|
+
state.accepted !== undefined
|
|
2725
|
+
? 'preserved an accepted device answer for this exact session to resume'
|
|
2726
|
+
: 'preserved required Agent Acknowledgement obligations for this exact session',
|
|
2727
|
+
)
|
|
2501
2728
|
return {
|
|
2502
2729
|
notes,
|
|
2503
2730
|
log: {
|
|
2504
|
-
outcome: 'answer-preserved',
|
|
2731
|
+
outcome: state.accepted !== undefined ? 'answer-preserved' : 'acknowledgement-preserved',
|
|
2505
2732
|
queued_retirements: orphans.length,
|
|
2506
|
-
accepted_answers: state.accepted
|
|
2733
|
+
accepted_answers: state.accepted?.answers.length ?? 0,
|
|
2734
|
+
acknowledgement_due: state.acknowledgement_due?.length ?? 0,
|
|
2507
2735
|
},
|
|
2508
2736
|
}
|
|
2509
2737
|
}
|
package/src/logging.ts
CHANGED
|
@@ -87,6 +87,10 @@ export const LOG_EVENTS = [
|
|
|
87
87
|
'ask.registered',
|
|
88
88
|
/** An answer came back from a device. */
|
|
89
89
|
'reply.received',
|
|
90
|
+
/** An agent attempted to acknowledge the user's reply. */
|
|
91
|
+
'acknowledgement.attempted',
|
|
92
|
+
/** The service recorded or replayed an Agent Acknowledgement. */
|
|
93
|
+
'acknowledgement.outcome',
|
|
90
94
|
/** A harness hook started. */
|
|
91
95
|
'hook.start',
|
|
92
96
|
/** A hook decided whether a question may leave the terminal, and why. */
|
package/src/main.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
3
|
import { Command } from 'commander'
|
|
4
4
|
import {
|
|
5
|
+
acknowledgeCommand,
|
|
5
6
|
askCommand,
|
|
6
7
|
accessStatusCommand,
|
|
7
8
|
authStatusCommand,
|
|
@@ -352,6 +353,19 @@ program
|
|
|
352
353
|
process.exit(await repliesCommand(deps, requestId, opts))
|
|
353
354
|
})
|
|
354
355
|
|
|
356
|
+
program
|
|
357
|
+
.command('acknowledge <request_id>')
|
|
358
|
+
.helpGroup(GROUP.agent)
|
|
359
|
+
.summary("Tell the user what you'll do because of their reply")
|
|
360
|
+
.description(
|
|
361
|
+
'Record the required Agent Acknowledgement for a replied-to notification request; never prompts',
|
|
362
|
+
)
|
|
363
|
+
.requiredOption('--text <text>', 'concrete work you will do because of the reply')
|
|
364
|
+
.option('--json', 'machine-readable output')
|
|
365
|
+
.action(async (requestId: string, opts: { text: string; json?: boolean }) => {
|
|
366
|
+
process.exit(await acknowledgeCommand(deps, requestId, opts))
|
|
367
|
+
})
|
|
368
|
+
|
|
355
369
|
program
|
|
356
370
|
.command('status <request_id>')
|
|
357
371
|
.helpGroup(GROUP.agent)
|
|
@@ -423,8 +437,9 @@ program
|
|
|
423
437
|
.helpGroup(GROUP.agent)
|
|
424
438
|
.summary('Retire a question so late answers are rejected')
|
|
425
439
|
.description('Retire a question so late answers are rejected rather than lost')
|
|
426
|
-
.
|
|
427
|
-
|
|
440
|
+
.option('--json', 'machine-readable output')
|
|
441
|
+
.action(async (requestId: string, opts: { json?: boolean }) => {
|
|
442
|
+
process.exit(await closeCommand(deps, requestId, opts))
|
|
428
443
|
})
|
|
429
444
|
|
|
430
445
|
// Hidden, not removed. It is an entry point the hook installer writes into a
|